diff --git a/homeassistant/components/fritz/diagnostics.py b/homeassistant/components/fritz/diagnostics.py index b9ae9edf04df56..e8cad15ec3b977 100644 --- a/homeassistant/components/fritz/diagnostics.py +++ b/homeassistant/components/fritz/diagnostics.py @@ -46,6 +46,9 @@ async def async_get_config_entry_diagnostics( } for _, device in avm_wrapper.devices.items() ], + "cpu_temperatures": await hass.async_add_executor_job( + avm_wrapper.fritz_status.get_cpu_temperatures + ), "wan_link_properties": await avm_wrapper.async_get_wan_link_properties(), }, } diff --git a/homeassistant/components/mcp/strings.json b/homeassistant/components/mcp/strings.json index 780b4818666dd2..5614609ecd4f4b 100644 --- a/homeassistant/components/mcp/strings.json +++ b/homeassistant/components/mcp/strings.json @@ -9,6 +9,18 @@ "url": "The remote MCP server URL for the SSE endpoint, for example http://example/sse" } }, + "credentials_choice": { + "title": "Choose how to authenticate with the MCP server", + "description": "You can either use existing credentials from another integration or set up new credentials.", + "menu_options": { + "new_credentials": "Set up new credentials", + "pick_implementation": "Use existing credentials" + }, + "menu_option_descriptions": { + "new_credentials": "You will be guided through setting up a new OAuth Client ID and secret.", + "pick_implementation": "You may use previously entered OAuth credentials." + } + }, "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", "data": { @@ -27,14 +39,21 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "missing_capabilities": "The MCP server does not support a required capability (Tools)", "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "reauth_account_mismatch": "The authenticated user does not match the MCP Server user that needed re-authentication.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" } } } diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 99039ab98220fb..396d26421cee54 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -74,21 +74,28 @@ class ReolinkSmartAIBinarySensorEntityDescription( ), ReolinkBinarySensorEntityDescription( key=PERSON_DETECTION_TYPE, - cmd_id=[33, 600], + cmd_id=[33, 600, 696], translation_key="person", value=lambda api, ch: api.ai_detected(ch, PERSON_DETECTION_TYPE), supported=lambda api, ch: api.ai_supported(ch, PERSON_DETECTION_TYPE), ), ReolinkBinarySensorEntityDescription( key=VEHICLE_DETECTION_TYPE, - cmd_id=[33, 600], + cmd_id=[33, 600, 696], translation_key="vehicle", value=lambda api, ch: api.ai_detected(ch, VEHICLE_DETECTION_TYPE), supported=lambda api, ch: api.ai_supported(ch, VEHICLE_DETECTION_TYPE), ), + ReolinkBinarySensorEntityDescription( + key="non-motor_vehicle", + cmd_id=[600, 696], + translation_key="non-motor_vehicle", + value=lambda api, ch: api.ai_detected(ch, "non-motor vehicle"), + supported=lambda api, ch: api.supported(ch, "ai_non-motor vehicle"), + ), ReolinkBinarySensorEntityDescription( key=PET_DETECTION_TYPE, - cmd_id=[33, 600], + cmd_id=[33, 600, 696], translation_key="pet", value=lambda api, ch: api.ai_detected(ch, PET_DETECTION_TYPE), supported=lambda api, ch: ( @@ -98,14 +105,14 @@ class ReolinkSmartAIBinarySensorEntityDescription( ), ReolinkBinarySensorEntityDescription( key=PET_DETECTION_TYPE, - cmd_id=[33, 600], + cmd_id=[33, 600, 696], translation_key="animal", value=lambda api, ch: api.ai_detected(ch, PET_DETECTION_TYPE), supported=lambda api, ch: api.supported(ch, "ai_animal"), ), ReolinkBinarySensorEntityDescription( key=PACKAGE_DETECTION_TYPE, - cmd_id=[33, 600], + cmd_id=[33, 600, 696], translation_key="package", value=lambda api, ch: api.ai_detected(ch, PACKAGE_DETECTION_TYPE), supported=lambda api, ch: api.ai_supported(ch, PACKAGE_DETECTION_TYPE), @@ -120,7 +127,7 @@ class ReolinkSmartAIBinarySensorEntityDescription( ), ReolinkBinarySensorEntityDescription( key="cry", - cmd_id=[33, 600], + cmd_id=[33], translation_key="cry", value=lambda api, ch: api.ai_detected(ch, "cry"), supported=lambda api, ch: api.ai_supported(ch, "cry"), diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index e2424aed43d0bb..736f8c947b2eea 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -13,6 +13,12 @@ "on": "mdi:car" } }, + "non-motor_vehicle": { + "default": "mdi:motorbike-off", + "state": { + "on": "mdi:motorbike" + } + }, "pet": { "default": "mdi:dog-side-off", "state": { diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 634b8d909e6557..c547aee39c2f35 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.15.2"] + "requirements": ["reolink-aio==0.16.0"] } diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index cdb10b7c687a62..8afb9188e57c0b 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -206,6 +206,13 @@ "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" } }, + "non-motor_vehicle": { + "name": "Bicycle", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, "pet": { "name": "Pet", "state": { diff --git a/homeassistant/components/shelly/icons.json b/homeassistant/components/shelly/icons.json index 832cf2b4c8f250..dfc5cbc2e68de6 100644 --- a/homeassistant/components/shelly/icons.json +++ b/homeassistant/components/shelly/icons.json @@ -20,6 +20,9 @@ } }, "sensor": { + "charger_state": { + "default": "mdi:ev-station" + }, "detected_objects": { "default": "mdi:account-group" }, diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 6e840bc67a6810..08a527591e01c3 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -33,6 +33,7 @@ UnitOfPower, UnitOfPressure, UnitOfTemperature, + UnitOfTime, UnitOfVolume, UnitOfVolumeFlowRate, ) @@ -1489,6 +1490,41 @@ def __init__( state_class=SensorStateClass.MEASUREMENT, role="water_temperature", ), + "number_work_state": RpcSensorDescription( + key="number", + sub_key="value", + translation_key="charger_state", + device_class=SensorDeviceClass.ENUM, + options=[ + "charger_charging", + "charger_end", + "charger_fault", + "charger_free", + "charger_free_fault", + "charger_insert", + "charger_pause", + "charger_wait", + ], + role="work_state", + ), + "number_energy_charge": RpcSensorDescription( + key="number", + sub_key="value", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + role="energy_charge", + ), + "number_time_charge": RpcSensorDescription( + key="number", + sub_key="value", + native_unit_of_measurement=UnitOfTime.MINUTES, + suggested_display_precision=0, + device_class=SensorDeviceClass.DURATION, + role="time_charge", + ), "presence_num_objects": RpcSensorDescription( key="presence", sub_key="num_objects", diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 1a11ecbb499321..294c5937ab0937 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -141,6 +141,18 @@ } }, "sensor": { + "charger_state": { + "state": { + "charger_charging": "[%key:common::state::charging%]", + "charger_end": "Charge completed", + "charger_fault": "Error while charging", + "charger_free": "[%key:component::binary_sensor::entity_component::plug::state::off%]", + "charger_free_fault": "Can not release plug", + "charger_insert": "[%key:component::binary_sensor::entity_component::plug::state::on%]", + "charger_pause": "Charging paused by charger", + "charger_wait": "Charging paused by vehicle" + } + }, "detected_objects": { "unit_of_measurement": "objects" }, diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py index dfca388e99e906..0fa1b0a5641266 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -5,7 +5,6 @@ import logging from typing import Any -import sqlparse import voluptuous as vol from homeassistant.components.recorder import CONF_DB_URL, get_instance @@ -40,23 +39,11 @@ DOMAIN, PLATFORMS, ) -from .util import redact_credentials +from .util import redact_credentials, validate_sql_select _LOGGER = logging.getLogger(__name__) -def validate_sql_select(value: str) -> str: - """Validate that value is a SQL SELECT query.""" - if len(query := sqlparse.parse(value.lstrip().lstrip(";"))) > 1: - raise vol.Invalid("Multiple SQL queries are not supported") - if len(query) == 0 or (query_type := query[0].get_type()) == "UNKNOWN": - raise vol.Invalid("Invalid SQL query") - if query_type != "SELECT": - _LOGGER.debug("The SQL query %s is of type %s", query, query_type) - raise vol.Invalid("Only SELECT queries allowed") - return str(query[0]) - - QUERY_SCHEMA = vol.Schema( { vol.Required(CONF_COLUMN_NAME): cv.string, diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index a1b7442162c0de..aca9644c5efad2 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -7,19 +7,11 @@ import logging from typing import Any -import sqlalchemy -from sqlalchemy import lambda_stmt from sqlalchemy.engine import Result from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.orm import Session, scoped_session, sessionmaker -from sqlalchemy.sql.lambdas import StatementLambdaElement -from sqlalchemy.util import LRUCache - -from homeassistant.components.recorder import ( - CONF_DB_URL, - SupportedDialect, - get_instance, -) +from sqlalchemy.orm import scoped_session + +from homeassistant.components.recorder import CONF_DB_URL, get_instance from homeassistant.components.sensor import CONF_STATE_CLASS from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -29,12 +21,10 @@ CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, - EVENT_HOMEASSISTANT_STOP, MATCH_ALL, ) -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError -from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, @@ -50,13 +40,16 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_ADVANCED_OPTIONS, CONF_COLUMN_NAME, CONF_QUERY, DOMAIN -from .models import SQLData -from .util import redact_credentials, resolve_db_url +from .util import ( + async_create_sessionmaker, + generate_lambda_stmt, + redact_credentials, + resolve_db_url, + validate_query, +) _LOGGER = logging.getLogger(__name__) -_SQL_LAMBDA_CACHE: LRUCache = LRUCache(1000) - TRIGGER_ENTITY_OPTIONS = ( CONF_AVAILABILITY, CONF_DEVICE_CLASS, @@ -145,36 +138,6 @@ async def async_setup_entry( ) -@callback -def _async_get_or_init_domain_data(hass: HomeAssistant) -> SQLData: - """Get or initialize domain data.""" - if DOMAIN in hass.data: - sql_data: SQLData = hass.data[DOMAIN] - return sql_data - - session_makers_by_db_url: dict[str, scoped_session] = {} - - # - # Ensure we dispose of all engines at shutdown - # to avoid unclean disconnects - # - # Shutdown all sessions in the executor since they will - # do blocking I/O - # - def _shutdown_db_engines(event: Event) -> None: - """Shutdown all database engines.""" - for sessmaker in session_makers_by_db_url.values(): - sessmaker.connection().engine.dispose() - - cancel_shutdown = hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, _shutdown_db_engines - ) - - sql_data = SQLData(cancel_shutdown, session_makers_by_db_url) - hass.data[DOMAIN] = sql_data - return sql_data - - async def async_setup_sensor( hass: HomeAssistant, trigger_entity_config: ConfigType, @@ -187,70 +150,16 @@ async def async_setup_sensor( async_add_entities: AddEntitiesCallback | AddConfigEntryEntitiesCallback, ) -> None: """Set up the SQL sensor.""" - try: - instance = get_instance(hass) - except KeyError: # No recorder loaded - uses_recorder_db = False - else: - uses_recorder_db = db_url == instance.db_url - sessmaker: scoped_session | None - sql_data = _async_get_or_init_domain_data(hass) - use_database_executor = False - if uses_recorder_db and instance.dialect_name == SupportedDialect.SQLITE: - use_database_executor = True - assert instance.engine is not None - sessmaker = scoped_session(sessionmaker(bind=instance.engine, future=True)) - # For other databases we need to create a new engine since - # we want the connection to use the default timezone and these - # database engines will use QueuePool as its only sqlite that - # needs our custom pool. If there is already a session maker - # for this db_url we can use that so we do not create a new engine - # for every sensor. - elif db_url in sql_data.session_makers_by_db_url: - sessmaker = sql_data.session_makers_by_db_url[db_url] - elif sessmaker := await hass.async_add_executor_job( - _validate_and_get_session_maker_for_db_url, db_url - ): - sql_data.session_makers_by_db_url[db_url] = sessmaker - else: + ( + sessmaker, + uses_recorder_db, + use_database_executor, + ) = await async_create_sessionmaker(hass, db_url) + if sessmaker is None: return + validate_query(hass, query_str, uses_recorder_db, unique_id) upper_query = query_str.upper() - if uses_recorder_db: - redacted_query = redact_credentials(query_str) - - issue_key = unique_id if unique_id else redacted_query - # If the query has a unique id and they fix it we can dismiss the issue - # but if it doesn't have a unique id they have to ignore it instead - - if ( - "ENTITY_ID," in upper_query or "ENTITY_ID " in upper_query - ) and "STATES_META" not in upper_query: - _LOGGER.error( - "The query `%s` contains the keyword `entity_id` but does not " - "reference the `states_meta` table. This will cause a full table " - "scan and database instability. Please check the documentation and use " - "`states_meta.entity_id` instead", - redacted_query, - ) - - ir.async_create_issue( - hass, - DOMAIN, - f"entity_id_query_does_full_table_scan_{issue_key}", - translation_key="entity_id_query_does_full_table_scan", - translation_placeholders={"query": redacted_query}, - is_fixable=False, - severity=ir.IssueSeverity.ERROR, - ) - raise ValueError( - "Query contains entity_id but does not reference states_meta" - ) - - ir.async_delete_issue( - hass, DOMAIN, f"entity_id_query_does_full_table_scan_{issue_key}" - ) - # MSSQL uses TOP and not LIMIT if not ("LIMIT" in upper_query or "SELECT TOP" in upper_query): if "mssql" in db_url: @@ -273,39 +182,6 @@ async def async_setup_sensor( ) -def _validate_and_get_session_maker_for_db_url(db_url: str) -> scoped_session | None: - """Validate the db_url and return a session maker. - - This does I/O and should be run in the executor. - """ - sess: Session | None = None - try: - engine = sqlalchemy.create_engine(db_url, future=True) - sessmaker = scoped_session(sessionmaker(bind=engine, future=True)) - # Run a dummy query just to test the db_url - sess = sessmaker() - sess.execute(sqlalchemy.text("SELECT 1;")) - - except SQLAlchemyError as err: - _LOGGER.error( - "Couldn't connect using %s DB_URL: %s", - redact_credentials(db_url), - redact_credentials(str(err)), - ) - return None - else: - return sessmaker - finally: - if sess: - sess.close() - - -def _generate_lambda_stmt(query: str) -> StatementLambdaElement: - """Generate the lambda statement.""" - text = sqlalchemy.text(query) - return lambda_stmt(lambda: text, lambda_cache=_SQL_LAMBDA_CACHE) - - class SQLSensor(ManualTriggerSensorEntity): """Representation of an SQL sensor.""" @@ -329,7 +205,7 @@ def __init__( self.sessionmaker = sessmaker self._attr_extra_state_attributes = {} self._use_database_executor = use_database_executor - self._lambda_stmt = _generate_lambda_stmt(query) + self._lambda_stmt = generate_lambda_stmt(query) if not yaml and (unique_id := trigger_entity_config.get(CONF_UNIQUE_ID)): self._attr_name = None self._attr_has_entity_name = True diff --git a/homeassistant/components/sql/util.py b/homeassistant/components/sql/util.py index 48fb53820ff78f..0200a83c9e8499 100644 --- a/homeassistant/components/sql/util.py +++ b/homeassistant/components/sql/util.py @@ -4,13 +4,27 @@ import logging -from homeassistant.components.recorder import get_instance -from homeassistant.core import HomeAssistant +import sqlalchemy +from sqlalchemy import lambda_stmt +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session, scoped_session, sessionmaker +from sqlalchemy.sql.lambdas import StatementLambdaElement +from sqlalchemy.util import LRUCache +import sqlparse +import voluptuous as vol -from .const import DB_URL_RE +from homeassistant.components.recorder import SupportedDialect, get_instance +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir + +from .const import DB_URL_RE, DOMAIN +from .models import SQLData _LOGGER = logging.getLogger(__name__) +_SQL_LAMBDA_CACHE: LRUCache = LRUCache(1000) + def redact_credentials(data: str | None) -> str: """Redact credentials from string data.""" @@ -25,3 +39,187 @@ def resolve_db_url(hass: HomeAssistant, db_url: str | None) -> str: if db_url and not db_url.isspace(): return db_url return get_instance(hass).db_url + + +def validate_sql_select(value: str) -> str: + """Validate that value is a SQL SELECT query.""" + if len(query := sqlparse.parse(value.lstrip().lstrip(";"))) > 1: + raise vol.Invalid("Multiple SQL queries are not supported") + if len(query) == 0 or (query_type := query[0].get_type()) == "UNKNOWN": + raise vol.Invalid("Invalid SQL query") + if query_type != "SELECT": + _LOGGER.debug("The SQL query %s is of type %s", query, query_type) + raise vol.Invalid("Only SELECT queries allowed") + return str(query[0]) + + +async def async_create_sessionmaker( + hass: HomeAssistant, db_url: str +) -> tuple[scoped_session | None, bool, bool]: + """Create a session maker for the given db_url. + + This function gets or creates a SQLAlchemy `scoped_session` for the given + db_url. It reuses existing connections where possible and handles the special + case for the default recorder's database to use the correct executor. + + Args: + hass: The Home Assistant instance. + db_url: The database URL to connect to. + + Returns: + A tuple containing the following items: + - (scoped_session | None): The SQLAlchemy session maker for executing + queries. This is `None` if a connection to the database could not + be established. + - (bool): A flag indicating if the query is against the recorder + database. + - (bool): A flag indicating if the dedicated recorder database + executor should be used. + + """ + try: + instance = get_instance(hass) + except KeyError: # No recorder loaded + uses_recorder_db = False + else: + uses_recorder_db = db_url == instance.db_url + sessmaker: scoped_session | None + sql_data = _async_get_or_init_domain_data(hass) + use_database_executor = False + if uses_recorder_db and instance.dialect_name == SupportedDialect.SQLITE: + use_database_executor = True + assert instance.engine is not None + sessmaker = scoped_session(sessionmaker(bind=instance.engine, future=True)) + # For other databases we need to create a new engine since + # we want the connection to use the default timezone and these + # database engines will use QueuePool as its only sqlite that + # needs our custom pool. If there is already a session maker + # for this db_url we can use that so we do not create a new engine + # for every sensor. + elif db_url in sql_data.session_makers_by_db_url: + sessmaker = sql_data.session_makers_by_db_url[db_url] + elif sessmaker := await hass.async_add_executor_job( + _validate_and_get_session_maker_for_db_url, db_url + ): + sql_data.session_makers_by_db_url[db_url] = sessmaker + else: + return (None, uses_recorder_db, use_database_executor) + + return (sessmaker, uses_recorder_db, use_database_executor) + + +def validate_query( + hass: HomeAssistant, + query_str: str, + uses_recorder_db: bool, + unique_id: str | None = None, +) -> None: + """Validate the query against common performance issues. + + Args: + hass: The Home Assistant instance. + query_str: The SQL query string to be validated. + uses_recorder_db: A boolean indicating if the query is against the recorder database. + unique_id: The unique ID of the entity, used for creating issue registry keys. + + Raises: + ValueError: If the query uses `entity_id` without referencing `states_meta`. + + """ + if not uses_recorder_db: + return + redacted_query = redact_credentials(query_str) + + issue_key = unique_id if unique_id else redacted_query + # If the query has a unique id and they fix it we can dismiss the issue + # but if it doesn't have a unique id they have to ignore it instead + + upper_query = query_str.upper() + if ( + "ENTITY_ID," in upper_query or "ENTITY_ID " in upper_query + ) and "STATES_META" not in upper_query: + _LOGGER.error( + "The query `%s` contains the keyword `entity_id` but does not " + "reference the `states_meta` table. This will cause a full table " + "scan and database instability. Please check the documentation and use " + "`states_meta.entity_id` instead", + redacted_query, + ) + + ir.async_create_issue( + hass, + DOMAIN, + f"entity_id_query_does_full_table_scan_{issue_key}", + translation_key="entity_id_query_does_full_table_scan", + translation_placeholders={"query": redacted_query}, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + ) + raise ValueError("Query contains entity_id but does not reference states_meta") + + ir.async_delete_issue( + hass, DOMAIN, f"entity_id_query_does_full_table_scan_{issue_key}" + ) + + +@callback +def _async_get_or_init_domain_data(hass: HomeAssistant) -> SQLData: + """Get or initialize domain data.""" + if DOMAIN in hass.data: + sql_data: SQLData = hass.data[DOMAIN] + return sql_data + + session_makers_by_db_url: dict[str, scoped_session] = {} + + # + # Ensure we dispose of all engines at shutdown + # to avoid unclean disconnects + # + # Shutdown all sessions in the executor since they will + # do blocking I/O + # + def _shutdown_db_engines(event: Event) -> None: + """Shutdown all database engines.""" + for sessmaker in session_makers_by_db_url.values(): + sessmaker.connection().engine.dispose() + + cancel_shutdown = hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, _shutdown_db_engines + ) + + sql_data = SQLData(cancel_shutdown, session_makers_by_db_url) + hass.data[DOMAIN] = sql_data + return sql_data + + +def _validate_and_get_session_maker_for_db_url(db_url: str) -> scoped_session | None: + """Validate the db_url and return a session maker. + + This does I/O and should be run in the executor. + """ + sess: Session | None = None + try: + engine = sqlalchemy.create_engine(db_url, future=True) + sessmaker = scoped_session(sessionmaker(bind=engine, future=True)) + # Run a dummy query just to test the db_url + sess = sessmaker() + sess.execute(sqlalchemy.text("SELECT 1;")) + + except SQLAlchemyError as err: + _LOGGER.error( + "Couldn't connect using %s DB_URL: %s", + redact_credentials(db_url), + redact_credentials(str(err)), + ) + return None + else: + return sessmaker + finally: + if sess: + sess.close() + + +def generate_lambda_stmt(query: str) -> StatementLambdaElement: + """Generate the lambda statement.""" + text = sqlalchemy.text(query) + return lambda_stmt(lambda: text, lambda_cache=_SQL_LAMBDA_CACHE) diff --git a/requirements_all.txt b/requirements_all.txt index 21e30ff1bee66a..963b249ee34c1c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2701,7 +2701,7 @@ renault-api==0.4.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.15.2 +reolink-aio==0.16.0 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 850870baefe9f9..fdd72ea16b9465 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2247,7 +2247,7 @@ renault-api==0.4.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.15.2 +reolink-aio==0.16.0 # homeassistant.components.rflink rflink==0.0.67 diff --git a/tests/components/fritz/snapshots/test_diagnostics.ambr b/tests/components/fritz/snapshots/test_diagnostics.ambr index c2ca866ceb6e18..dead09cae4a895 100644 --- a/tests/components/fritz/snapshots/test_diagnostics.ambr +++ b/tests/components/fritz/snapshots/test_diagnostics.ambr @@ -12,6 +12,11 @@ }), ]), 'connection_type': 'WANPPPConnection', + 'cpu_temperatures': list([ + 69, + 68, + 67, + ]), 'current_firmware': '7.29', 'discovered_services': list([ 'DeviceInfo1', diff --git a/tests/components/shelly/snapshots/test_sensor.ambr b/tests/components/shelly/snapshots/test_sensor.ambr index 4b12dddae627ac..6188d44922c0a7 100644 --- a/tests/components/shelly/snapshots/test_sensor.ambr +++ b/tests/components/shelly/snapshots/test_sensor.ambr @@ -157,6 +157,188 @@ 'state': '0', }) # --- +# name: test_rpc_shelly_ev_sensors[sensor.test_name_charger_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'charger_charging', + 'charger_end', + 'charger_fault', + 'charger_free', + 'charger_free_fault', + 'charger_insert', + 'charger_pause', + 'charger_wait', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_name_charger_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charger state', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charger_state', + 'unique_id': '123456789ABC-number:200-number_work_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_shelly_ev_sensors[sensor.test_name_charger_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test name Charger state', + 'options': list([ + 'charger_charging', + 'charger_end', + 'charger_fault', + 'charger_free', + 'charger_free_fault', + 'charger_insert', + 'charger_pause', + 'charger_wait', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_name_charger_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'charger_charging', + }) +# --- +# name: test_rpc_shelly_ev_sensors[sensor.test_name_session_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_name_session_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Session duration', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-number:202-number_time_charge', + 'unit_of_measurement': , + }) +# --- +# name: test_rpc_shelly_ev_sensors[sensor.test_name_session_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test name Session duration', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_session_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_rpc_shelly_ev_sensors[sensor.test_name_session_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_name_session_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Session energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-number:201-number_energy_charge', + 'unit_of_measurement': , + }) +# --- +# name: test_rpc_shelly_ev_sensors[sensor.test_name_session_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Session energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_session_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- # name: test_rpc_switch_energy_sensors[sensor.test_name_test_switch_0_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index 1bf2a0e60a95a9..015afdd36611fc 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -1672,6 +1672,74 @@ async def test_rpc_switch_no_returned_energy_sensor( assert hass.states.get("sensor.test_name_test_switch_0_returned_energy") is None +async def test_rpc_shelly_ev_sensors( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test Shelly EV sensors.""" + config = deepcopy(mock_rpc_device.config) + config["number:200"] = { + "name": "Charger state", + "meta": { + "ui": { + "titles": { + "charger_charging": "Charging", + "charger_end": "End", + "charger_fault": "Fault", + "charger_free": "Free", + "charger_free_fault": "Free fault", + "charger_insert": "Insert", + "charger_pause": "Pause", + "charger_wait": "Wait", + }, + "view": "label", + } + }, + "options": [ + "charger_free", + "charger_insert", + "charger_free_fault", + "charger_wait", + "charger_charging", + "charger_pause", + "charger_end", + "charger_fault", + ], + "role": "work_state", + } + config["number:201"] = { + "name": "Session energy", + "meta": {"ui": {"unit": "Wh", "view": "label"}}, + "role": "energy_charge", + } + config["number:202"] = { + "name": "Session duration", + "meta": {"ui": {"unit": "min", "view": "label"}}, + "role": "time_charge", + } + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["number:200"] = {"value": "charger_charging"} + status["number:201"] = {"value": 5000} + status["number:202"] = {"value": 60} + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 3) + + for entity in ("charger_state", "session_energy", "session_duration"): + entity_id = f"{SENSOR_DOMAIN}.test_name_{entity}" + + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") + + async def test_block_friendly_name_sleeping_sensor( hass: HomeAssistant, mock_block_device: Mock, diff --git a/tests/components/sql/test_init.py b/tests/components/sql/test_init.py index 7236b7212d3b1d..c07d5c9e63936f 100644 --- a/tests/components/sql/test_init.py +++ b/tests/components/sql/test_init.py @@ -4,16 +4,12 @@ from unittest.mock import patch -import pytest -import voluptuous as vol - from homeassistant.components.recorder import CONF_DB_URL, Recorder from homeassistant.components.sensor import ( CONF_STATE_CLASS, SensorDeviceClass, SensorStateClass, ) -from homeassistant.components.sql import validate_sql_select from homeassistant.components.sql.const import ( CONF_ADVANCED_OPTIONS, CONF_COLUMN_NAME, @@ -71,38 +67,6 @@ async def test_setup_invalid_config( await hass.async_block_till_done() -async def test_invalid_query(hass: HomeAssistant) -> None: - """Test invalid query.""" - with pytest.raises(vol.Invalid): - validate_sql_select("DROP TABLE *") - - with pytest.raises(vol.Invalid): - validate_sql_select("SELECT5 as value") - - with pytest.raises(vol.Invalid): - validate_sql_select(";;") - - -async def test_query_no_read_only(hass: HomeAssistant) -> None: - """Test query no read only.""" - with pytest.raises(vol.Invalid): - validate_sql_select("UPDATE states SET state = 999999 WHERE state_id = 11125") - - -async def test_query_no_read_only_cte(hass: HomeAssistant) -> None: - """Test query no read only CTE.""" - with pytest.raises(vol.Invalid): - validate_sql_select( - "WITH test AS (SELECT state FROM states) UPDATE states SET states.state = test.state;" - ) - - -async def test_multiple_queries(hass: HomeAssistant) -> None: - """Test multiple queries.""" - with pytest.raises(vol.Invalid): - validate_sql_select("SELECT 5 as value; UPDATE states SET state = 10;") - - async def test_migration_from_future( recorder_mock: Recorder, hass: HomeAssistant ) -> None: diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index aa14be2f643f0d..388c4966e7b979 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -24,7 +24,7 @@ CONF_QUERY, DOMAIN, ) -from homeassistant.components.sql.sensor import _generate_lambda_stmt +from homeassistant.components.sql.util import generate_lambda_stmt from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_DEVICE_CLASS, @@ -229,7 +229,7 @@ async def test_invalid_url_setup( entry.add_to_hass(hass) with patch( - "homeassistant.components.sql.sensor.sqlalchemy.create_engine", + "homeassistant.components.sql.util.sqlalchemy.create_engine", side_effect=SQLAlchemyError(url), ): await hass.config_entries.async_setup(entry.entry_id) @@ -260,7 +260,7 @@ def execute(self, query: Any) -> None: raise SQLAlchemyError("sqlite://homeassistant:hunter2@homeassistant.local") with patch( - "homeassistant.components.sql.sensor.scoped_session", + "homeassistant.components.sql.util.scoped_session", return_value=MockSession, ): await init_integration(hass, title="count_tables", options=options) @@ -402,7 +402,7 @@ async def test_invalid_url_setup_from_yaml( } with patch( - "homeassistant.components.sql.sensor.sqlalchemy.create_engine", + "homeassistant.components.sql.util.sqlalchemy.create_engine", side_effect=SQLAlchemyError(url), ): assert await async_setup_component(hass, DOMAIN, config) @@ -648,7 +648,7 @@ async def test_query_recover_from_rollback( with patch.object( sql_entity, "_lambda_stmt", - _generate_lambda_stmt("Faulty syntax create operational issue"), + generate_lambda_stmt("Faulty syntax create operational issue"), ): freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) diff --git a/tests/components/sql/test_util.py b/tests/components/sql/test_util.py index 004b511a2f00c0..737a5e4a41baac 100644 --- a/tests/components/sql/test_util.py +++ b/tests/components/sql/test_util.py @@ -1,7 +1,10 @@ """Test the sql utils.""" +import pytest +import voluptuous as vol + from homeassistant.components.recorder import Recorder, get_instance -from homeassistant.components.sql.util import resolve_db_url +from homeassistant.components.sql.util import resolve_db_url, validate_sql_select from homeassistant.core import HomeAssistant @@ -22,3 +25,42 @@ async def test_resolve_db_url_when_configured(hass: HomeAssistant) -> None: resolved_url = resolve_db_url(hass, db_url) assert resolved_url == db_url + + +@pytest.mark.parametrize( + ("sql_query", "expected_error_message"), + [ + ( + "DROP TABLE *", + "Only SELECT queries allowed", + ), + ( + "SELECT5 as value", + "Invalid SQL query", + ), + ( + ";;", + "Invalid SQL query", + ), + ( + "UPDATE states SET state = 999999 WHERE state_id = 11125", + "Only SELECT queries allowed", + ), + ( + "WITH test AS (SELECT state FROM states) UPDATE states SET states.state = test.state;", + "Only SELECT queries allowed", + ), + ( + "SELECT 5 as value; UPDATE states SET state = 10;", + "Multiple SQL queries are not supported", + ), + ], +) +async def test_invalid_sql_queries( + hass: HomeAssistant, + sql_query: str, + expected_error_message: str, +) -> None: + """Test that various invalid or disallowed SQL queries raise the correct exception.""" + with pytest.raises(vol.Invalid, match=expected_error_message): + validate_sql_select(sql_query)