From e65b4292b25405ca39e1106c19ab45f6f9b2026d Mon Sep 17 00:00:00 2001 From: Foscam-wangzhengyu Date: Thu, 11 Sep 2025 17:59:05 +0800 Subject: [PATCH 01/15] Add volume control to Foscam Upgrade dependencies (#150618) Co-authored-by: Joostlek --- homeassistant/components/foscam/__init__.py | 3 +- .../components/foscam/coordinator.py | 14 ++- homeassistant/components/foscam/entity.py | 2 + homeassistant/components/foscam/icons.json | 8 ++ homeassistant/components/foscam/number.py | 93 ++++++++++++++ homeassistant/components/foscam/strings.json | 8 ++ homeassistant/components/foscam/switch.py | 2 - tests/components/foscam/conftest.py | 10 +- .../foscam/snapshots/test_number.ambr | 115 ++++++++++++++++++ tests/components/foscam/test_number.py | 62 ++++++++++ 10 files changed, 311 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/foscam/number.py create mode 100644 tests/components/foscam/snapshots/test_number.ambr create mode 100644 tests/components/foscam/test_number.py diff --git a/homeassistant/components/foscam/__init__.py b/homeassistant/components/foscam/__init__.py index 099123ccd9bfee..e9ad1e78cfc403 100644 --- a/homeassistant/components/foscam/__init__.py +++ b/homeassistant/components/foscam/__init__.py @@ -16,7 +16,7 @@ from .const import CONF_RTSP_PORT, LOGGER from .coordinator import FoscamConfigEntry, FoscamCoordinator -PLATFORMS = [Platform.CAMERA, Platform.SWITCH] +PLATFORMS = [Platform.CAMERA, Platform.NUMBER, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: FoscamConfigEntry) -> bool: @@ -29,6 +29,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FoscamConfigEntry) -> bo entry.data[CONF_PASSWORD], verbose=False, ) + coordinator = FoscamCoordinator(hass, entry, session) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/foscam/coordinator.py b/homeassistant/components/foscam/coordinator.py index 50ddd76ddb3925..80b6ec96e835a2 100644 --- a/homeassistant/components/foscam/coordinator.py +++ b/homeassistant/components/foscam/coordinator.py @@ -30,10 +30,11 @@ class FoscamDeviceInfo: is_open_white_light: bool is_siren_alarm: bool - volume: int + device_volume: int speak_volume: int is_turn_off_volume: bool is_turn_off_light: bool + supports_speak_volume_adjustment: bool is_open_wdr: bool | None = None is_open_hdr: bool | None = None @@ -118,6 +119,14 @@ def gather_all_configs(self) -> FoscamDeviceInfo: mode = is_open_hdr_data["mode"] if ret_hdr == 0 and is_open_hdr_data else 0 is_open_hdr = bool(int(mode)) + ret_sw, software_capabilities = self.session.getSWCapabilities() + + supports_speak_volume_adjustment_val = ( + bool(int(software_capabilities.get("swCapabilities1")) & 32) + if ret_sw == 0 + else False + ) + return FoscamDeviceInfo( dev_info=dev_info, product_info=product_info, @@ -127,10 +136,11 @@ def gather_all_configs(self) -> FoscamDeviceInfo: is_asleep=is_asleep, is_open_white_light=is_open_white_light_val, is_siren_alarm=is_siren_alarm_val, - volume=volume_val, + device_volume=volume_val, speak_volume=speak_volume_val, is_turn_off_volume=is_turn_off_volume_val, is_turn_off_light=is_turn_off_light_val, + supports_speak_volume_adjustment=supports_speak_volume_adjustment_val, is_open_wdr=is_open_wdr, is_open_hdr=is_open_hdr, ) diff --git a/homeassistant/components/foscam/entity.py b/homeassistant/components/foscam/entity.py index 7bc983cbfaae29..e9930695a75409 100644 --- a/homeassistant/components/foscam/entity.py +++ b/homeassistant/components/foscam/entity.py @@ -13,6 +13,8 @@ class FoscamEntity(CoordinatorEntity[FoscamCoordinator]): """Base entity for Foscam camera.""" + _attr_has_entity_name = True + def __init__(self, coordinator: FoscamCoordinator, config_entry_id: str) -> None: """Initialize the base Foscam entity.""" super().__init__(coordinator) diff --git a/homeassistant/components/foscam/icons.json b/homeassistant/components/foscam/icons.json index 4b0b0c17c3229c..7dbd874b2f6840 100644 --- a/homeassistant/components/foscam/icons.json +++ b/homeassistant/components/foscam/icons.json @@ -39,6 +39,14 @@ "wdr_switch": { "default": "mdi:alpha-w-box" } + }, + "number": { + "device_volume": { + "default": "mdi:volume-source" + }, + "speak_volume": { + "default": "mdi:account-voice" + } } } } diff --git a/homeassistant/components/foscam/number.py b/homeassistant/components/foscam/number.py new file mode 100644 index 00000000000000..e828955870d07b --- /dev/null +++ b/homeassistant/components/foscam/number.py @@ -0,0 +1,93 @@ +"""Foscam number platform for Home Assistant.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from libpyfoscamcgi import FoscamCamera + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import FoscamConfigEntry, FoscamCoordinator +from .entity import FoscamEntity + + +@dataclass(frozen=True, kw_only=True) +class FoscamNumberEntityDescription(NumberEntityDescription): + """A custom entity description with adjustable features.""" + + native_value_fn: Callable[[FoscamCoordinator], int] + set_value_fn: Callable[[FoscamCamera, float], Any] + exists_fn: Callable[[FoscamCoordinator], bool] + + +NUMBER_DESCRIPTIONS: list[FoscamNumberEntityDescription] = [ + FoscamNumberEntityDescription( + key="device_volume", + translation_key="device_volume", + native_min_value=0, + native_max_value=100, + native_step=1, + native_value_fn=lambda coordinator: coordinator.data.device_volume, + set_value_fn=lambda session, value: session.setAudioVolume(value), + exists_fn=lambda _: True, + ), + FoscamNumberEntityDescription( + key="speak_volume", + translation_key="speak_volume", + native_min_value=0, + native_max_value=100, + native_step=1, + native_value_fn=lambda coordinator: coordinator.data.speak_volume, + set_value_fn=lambda session, value: session.setSpeakVolume(value), + exists_fn=lambda coordinator: coordinator.data.supports_speak_volume_adjustment, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: FoscamConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up foscam number from a config entry.""" + coordinator = config_entry.runtime_data + async_add_entities( + FoscamVolumeNumberEntity(coordinator, description) + for description in NUMBER_DESCRIPTIONS + if description.exists_fn is None or description.exists_fn(coordinator) + ) + + +class FoscamVolumeNumberEntity(FoscamEntity, NumberEntity): + """Representation of a Foscam Smart AI number entity.""" + + entity_description: FoscamNumberEntityDescription + + def __init__( + self, + coordinator: FoscamCoordinator, + description: FoscamNumberEntityDescription, + ) -> None: + """Initialize the data.""" + entry_id = coordinator.config_entry.entry_id + super().__init__(coordinator, entry_id) + + self.entity_description = description + self._attr_unique_id = f"{entry_id}_{description.key}" + + @property + def native_value(self) -> float: + """Return the current value.""" + return self.entity_description.native_value_fn(self.coordinator) + + async def async_set_native_value(self, value: float) -> None: + """Set the value.""" + await self.hass.async_add_executor_job( + self.entity_description.set_value_fn, self.coordinator.session, value + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/foscam/strings.json b/homeassistant/components/foscam/strings.json index d73833b1caef4b..86a5ba59c0a846 100644 --- a/homeassistant/components/foscam/strings.json +++ b/homeassistant/components/foscam/strings.json @@ -62,6 +62,14 @@ "wdr_switch": { "name": "WDR" } + }, + "number": { + "device_volume": { + "name": "Device volume" + }, + "speak_volume": { + "name": "Speak volume" + } } }, "services": { diff --git a/homeassistant/components/foscam/switch.py b/homeassistant/components/foscam/switch.py index 91118a272775fb..8407da8edd3a51 100644 --- a/homeassistant/components/foscam/switch.py +++ b/homeassistant/components/foscam/switch.py @@ -121,7 +121,6 @@ async def async_setup_entry( """Set up foscam switch from a config entry.""" coordinator = config_entry.runtime_data - await coordinator.async_config_entry_first_refresh() entities = [] @@ -146,7 +145,6 @@ async def async_setup_entry( class FoscamGenericSwitch(FoscamEntity, SwitchEntity): """A generic switch class for Foscam entities.""" - _attr_has_entity_name = True entity_description: FoscamSwitchEntityDescription def __init__( diff --git a/tests/components/foscam/conftest.py b/tests/components/foscam/conftest.py index 4361669330321b..a7a5b1abe482c9 100644 --- a/tests/components/foscam/conftest.py +++ b/tests/components/foscam/conftest.py @@ -75,7 +75,15 @@ def configure_mock_on_init(host, port, user, passwd, verbose=False): mock_foscam_camera.getWdrMode.return_value = (0, {"mode": "0"}) mock_foscam_camera.getHdrMode.return_value = (0, {"mode": "0"}) mock_foscam_camera.get_motion_detect_config.return_value = (0, 1) - + mock_foscam_camera.getSWCapabilities.return_value = ( + 0, + { + "swCapabilities1": "100", + "swCapbilities2": "100", + "swCapbilities3": "100", + "swCapbilities4": "100", + }, + ) return mock_foscam_camera mock_foscam_camera.side_effect = configure_mock_on_init diff --git a/tests/components/foscam/snapshots/test_number.ambr b/tests/components/foscam/snapshots/test_number.ambr new file mode 100644 index 00000000000000..74294c7306ad3a --- /dev/null +++ b/tests/components/foscam/snapshots/test_number.ambr @@ -0,0 +1,115 @@ +# serializer version: 1 +# name: test_number_entities[number.mock_title_device_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.mock_title_device_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Device volume', + 'platform': 'foscam', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'device_volume', + 'unique_id': '123ABC_device_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entities[number.mock_title_device_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Device volume', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.mock_title_device_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_number_entities[number.mock_title_speak_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.mock_title_speak_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Speak volume', + 'platform': 'foscam', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'speak_volume', + 'unique_id': '123ABC_speak_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entities[number.mock_title_speak_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Speak volume', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.mock_title_speak_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- diff --git a/tests/components/foscam/test_number.py b/tests/components/foscam/test_number.py new file mode 100644 index 00000000000000..94088c94895924 --- /dev/null +++ b/tests/components/foscam/test_number.py @@ -0,0 +1,62 @@ +"""Test the Foscam number platform.""" + +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.foscam.const import DOMAIN +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import setup_mock_foscam_camera +from .const import ENTRY_ID, VALID_CONFIG + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_number_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test creation of number entities.""" + entry = MockConfigEntry(domain=DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID) + entry.add_to_hass(hass) + + with ( + # Mock a valid camera instance + patch("homeassistant.components.foscam.FoscamCamera") as mock_foscam_camera, + patch("homeassistant.components.foscam.PLATFORMS", [Platform.NUMBER]), + ): + setup_mock_foscam_camera(mock_foscam_camera) + assert await hass.config_entries.async_setup(entry.entry_id) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_setting_number(hass: HomeAssistant) -> None: + """Test setting a number entity calls the correct method on the camera.""" + entry = MockConfigEntry(domain=DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID) + entry.add_to_hass(hass) + + with patch("homeassistant.components.foscam.FoscamCamera") as mock_foscam_camera: + setup_mock_foscam_camera(mock_foscam_camera) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.mock_title_device_volume", + ATTR_VALUE: 42, + }, + blocking=True, + ) + mock_foscam_camera.setAudioVolume.assert_called_once_with(42) From 1428b41a25dd0a6273837b85bc0c67a91a9b5e44 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 11 Sep 2025 12:27:48 +0200 Subject: [PATCH 02/15] Improve sql config flow (#150757) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/sql/__init__.py | 67 +- homeassistant/components/sql/config_flow.py | 191 +++-- homeassistant/components/sql/const.py | 1 + homeassistant/components/sql/sensor.py | 12 +- homeassistant/components/sql/strings.json | 80 +- tests/components/sql/__init__.py | 119 ++- tests/components/sql/conftest.py | 17 + tests/components/sql/test_config_flow.py | 864 ++++++++++---------- tests/components/sql/test_init.py | 115 ++- tests/components/sql/test_sensor.py | 256 +++--- 10 files changed, 966 insertions(+), 756 deletions(-) create mode 100644 tests/components/sql/conftest.py diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py index 33ed64be2bf63f..dfca388e99e906 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import Any import sqlparse import voluptuous as vol @@ -32,7 +33,13 @@ ) from homeassistant.helpers.typing import ConfigType -from .const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN, PLATFORMS +from .const import ( + CONF_ADVANCED_OPTIONS, + CONF_COLUMN_NAME, + CONF_QUERY, + DOMAIN, + PLATFORMS, +) from .util import redact_credentials _LOGGER = logging.getLogger(__name__) @@ -75,18 +82,6 @@ def validate_sql_select(value: str) -> str: ) -def remove_configured_db_url_if_not_needed( - hass: HomeAssistant, entry: ConfigEntry -) -> None: - """Remove db url from config if it matches recorder database.""" - hass.config_entries.async_update_entry( - entry, - options={ - key: value for key, value in entry.options.items() if key != CONF_DB_URL - }, - ) - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up SQL from yaml config.""" if (conf := config.get(DOMAIN)) is None: @@ -107,8 +102,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: redact_credentials(entry.options.get(CONF_DB_URL)), redact_credentials(get_instance(hass).db_url), ) - if entry.options.get(CONF_DB_URL) == get_instance(hass).db_url: - remove_configured_db_url_if_not_needed(hass, entry) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -119,3 +112,47 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload SQL config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version) + + if entry.version > 1: + # This means the user has downgraded from a future version + return False + + if entry.version == 1: + old_options = {**entry.options} + new_data = {} + new_options: dict[str, Any] = {} + + if (db_url := old_options.get(CONF_DB_URL)) and db_url != get_instance( + hass + ).db_url: + new_data[CONF_DB_URL] = db_url + + new_options[CONF_COLUMN_NAME] = old_options.get(CONF_COLUMN_NAME) + new_options[CONF_QUERY] = old_options.get(CONF_QUERY) + new_options[CONF_ADVANCED_OPTIONS] = {} + + for key in ( + CONF_VALUE_TEMPLATE, + CONF_UNIT_OF_MEASUREMENT, + CONF_DEVICE_CLASS, + CONF_STATE_CLASS, + ): + if (value := old_options.get(key)) is not None: + new_options[CONF_ADVANCED_OPTIONS][key] = value + + hass.config_entries.async_update_entry( + entry, data=new_data, options=new_options, version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + entry.version, + entry.minor_version, + ) + + return True diff --git a/homeassistant/components/sql/config_flow.py b/homeassistant/components/sql/config_flow.py index 37a6f9ef104011..a614105d8bc555 100644 --- a/homeassistant/components/sql/config_flow.py +++ b/homeassistant/components/sql/config_flow.py @@ -6,7 +6,7 @@ from typing import Any import sqlalchemy -from sqlalchemy.engine import Result +from sqlalchemy.engine import Engine, Result from sqlalchemy.exc import MultipleResultsFound, NoSuchColumnError, SQLAlchemyError from sqlalchemy.orm import Session, scoped_session, sessionmaker import sqlparse @@ -32,9 +32,10 @@ CONF_VALUE_TEMPLATE, ) from homeassistant.core import callback +from homeassistant.data_entry_flow import section from homeassistant.helpers import selector -from .const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN +from .const import CONF_ADVANCED_OPTIONS, CONF_COLUMN_NAME, CONF_QUERY, DOMAIN from .util import resolve_db_url _LOGGER = logging.getLogger(__name__) @@ -42,40 +43,38 @@ OPTIONS_SCHEMA: vol.Schema = vol.Schema( { - vol.Optional( - CONF_DB_URL, - ): selector.TextSelector(), - vol.Required( - CONF_COLUMN_NAME, - ): selector.TextSelector(), - vol.Required( - CONF_QUERY, - ): selector.TextSelector(selector.TextSelectorConfig(multiline=True)), - vol.Optional( - CONF_UNIT_OF_MEASUREMENT, - ): selector.TextSelector(), - vol.Optional( - CONF_VALUE_TEMPLATE, - ): selector.TemplateSelector(), - vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector( - selector.SelectSelectorConfig( - options=[ - cls.value - for cls in SensorDeviceClass - if cls != SensorDeviceClass.ENUM - ], - mode=selector.SelectSelectorMode.DROPDOWN, - translation_key="device_class", - sort=True, - ) + vol.Required(CONF_QUERY): selector.TextSelector( + selector.TextSelectorConfig(multiline=True) ), - vol.Optional(CONF_STATE_CLASS): selector.SelectSelector( - selector.SelectSelectorConfig( - options=[cls.value for cls in SensorStateClass], - mode=selector.SelectSelectorMode.DROPDOWN, - translation_key="state_class", - sort=True, - ) + vol.Required(CONF_COLUMN_NAME): selector.TextSelector(), + vol.Required(CONF_ADVANCED_OPTIONS): section( + vol.Schema( + { + vol.Optional(CONF_VALUE_TEMPLATE): selector.TemplateSelector(), + vol.Optional(CONF_UNIT_OF_MEASUREMENT): selector.TextSelector(), + vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + cls.value + for cls in SensorDeviceClass + if cls != SensorDeviceClass.ENUM + ], + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="device_class", + sort=True, + ) + ), + vol.Optional(CONF_STATE_CLASS): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[cls.value for cls in SensorStateClass], + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="state_class", + sort=True, + ) + ), + } + ), + {"collapsed": True}, ), } ) @@ -83,8 +82,9 @@ CONFIG_SCHEMA: vol.Schema = vol.Schema( { vol.Required(CONF_NAME, default="Select SQL Query"): selector.TextSelector(), + vol.Optional(CONF_DB_URL): selector.TextSelector(), } -).extend(OPTIONS_SCHEMA.schema) +) def validate_sql_select(value: str) -> str: @@ -99,6 +99,31 @@ def validate_sql_select(value: str) -> str: return str(query[0]) +def validate_db_connection(db_url: str) -> bool: + """Validate db connection.""" + + engine: Engine | None = None + sess: Session | None = None + try: + engine = sqlalchemy.create_engine(db_url, future=True) + sessmaker = scoped_session(sessionmaker(bind=engine, future=True)) + sess = sessmaker() + sess.execute(sqlalchemy.text("select 1 as value")) + except SQLAlchemyError as error: + _LOGGER.debug("Execution error %s", error) + if sess: + sess.close() + if engine: + engine.dispose() + raise + + if sess: + sess.close() + engine.dispose() + + return True + + def validate_query(db_url: str, query: str, column: str) -> bool: """Validate SQL query.""" @@ -136,7 +161,9 @@ def validate_query(db_url: str, query: str, column: str) -> bool: class SQLConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for SQL integration.""" - VERSION = 1 + VERSION = 2 + + data: dict[str, Any] @staticmethod @callback @@ -151,17 +178,46 @@ async def async_step_user( ) -> ConfigFlowResult: """Handle the user step.""" errors = {} - description_placeholders = {} if user_input is not None: db_url = user_input.get(CONF_DB_URL) + + try: + db_url_for_validation = resolve_db_url(self.hass, db_url) + await self.hass.async_add_executor_job( + validate_db_connection, db_url_for_validation + ) + except SQLAlchemyError: + errors["db_url"] = "db_url_invalid" + + if not errors: + self.data = {CONF_NAME: user_input[CONF_NAME]} + if db_url and db_url_for_validation != get_instance(self.hass).db_url: + self.data[CONF_DB_URL] = db_url + return await self.async_step_options() + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema(CONFIG_SCHEMA, user_input), + errors=errors, + ) + + async def async_step_options( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step.""" + errors = {} + description_placeholders = {} + + if user_input is not None: query = user_input[CONF_QUERY] column = user_input[CONF_COLUMN_NAME] - db_url_for_validation = None try: query = validate_sql_select(query) - db_url_for_validation = resolve_db_url(self.hass, db_url) + db_url_for_validation = resolve_db_url( + self.hass, self.data.get(CONF_DB_URL) + ) await self.hass.async_add_executor_job( validate_query, db_url_for_validation, query, column ) @@ -178,32 +234,25 @@ async def async_step_user( _LOGGER.debug("Invalid query: %s", err) errors["query"] = "query_invalid" - options = { - CONF_QUERY: query, - CONF_COLUMN_NAME: column, - CONF_NAME: user_input[CONF_NAME], + mod_advanced_options = { + k: v + for k, v in user_input[CONF_ADVANCED_OPTIONS].items() + if v is not None } - if uom := user_input.get(CONF_UNIT_OF_MEASUREMENT): - options[CONF_UNIT_OF_MEASUREMENT] = uom - if value_template := user_input.get(CONF_VALUE_TEMPLATE): - options[CONF_VALUE_TEMPLATE] = value_template - if device_class := user_input.get(CONF_DEVICE_CLASS): - options[CONF_DEVICE_CLASS] = device_class - if state_class := user_input.get(CONF_STATE_CLASS): - options[CONF_STATE_CLASS] = state_class - if db_url_for_validation != get_instance(self.hass).db_url: - options[CONF_DB_URL] = db_url_for_validation + user_input[CONF_ADVANCED_OPTIONS] = mod_advanced_options if not errors: + name = self.data[CONF_NAME] + self.data.pop(CONF_NAME) return self.async_create_entry( - title=user_input[CONF_NAME], - data={}, - options=options, + title=name, + data=self.data, + options=user_input, ) return self.async_show_form( - step_id="user", - data_schema=self.add_suggested_values_to_schema(CONFIG_SCHEMA, user_input), + step_id="options", + data_schema=self.add_suggested_values_to_schema(OPTIONS_SCHEMA, user_input), errors=errors, description_placeholders=description_placeholders, ) @@ -220,10 +269,9 @@ async def async_step_init( description_placeholders = {} if user_input is not None: - db_url = user_input.get(CONF_DB_URL) + db_url = self.config_entry.data.get(CONF_DB_URL) query = user_input[CONF_QUERY] column = user_input[CONF_COLUMN_NAME] - name = self.config_entry.options.get(CONF_NAME, self.config_entry.title) try: query = validate_sql_select(query) @@ -252,24 +300,15 @@ async def async_step_init( recorder_db, ) - options = { - CONF_QUERY: query, - CONF_COLUMN_NAME: column, - CONF_NAME: name, + mod_advanced_options = { + k: v + for k, v in user_input[CONF_ADVANCED_OPTIONS].items() + if v is not None } - if uom := user_input.get(CONF_UNIT_OF_MEASUREMENT): - options[CONF_UNIT_OF_MEASUREMENT] = uom - if value_template := user_input.get(CONF_VALUE_TEMPLATE): - options[CONF_VALUE_TEMPLATE] = value_template - if device_class := user_input.get(CONF_DEVICE_CLASS): - options[CONF_DEVICE_CLASS] = device_class - if state_class := user_input.get(CONF_STATE_CLASS): - options[CONF_STATE_CLASS] = state_class - if db_url_for_validation != get_instance(self.hass).db_url: - options[CONF_DB_URL] = db_url_for_validation + user_input[CONF_ADVANCED_OPTIONS] = mod_advanced_options return self.async_create_entry( - data=options, + data=user_input, ) return self.async_show_form( diff --git a/homeassistant/components/sql/const.py b/homeassistant/components/sql/const.py index d8d13ab1699222..20e54c52abfa6b 100644 --- a/homeassistant/components/sql/const.py +++ b/homeassistant/components/sql/const.py @@ -9,4 +9,5 @@ CONF_COLUMN_NAME = "column" CONF_QUERY = "query" +CONF_ADVANCED_OPTIONS = "advanced_options" DB_URL_RE = re.compile("//.*:.*@") diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 8c0ba81d6d2392..a1b7442162c0de 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -49,7 +49,7 @@ ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN +from .const import CONF_ADVANCED_OPTIONS, CONF_COLUMN_NAME, CONF_QUERY, DOMAIN from .models import SQLData from .util import redact_credentials, resolve_db_url @@ -111,10 +111,10 @@ async def async_setup_entry( ) -> None: """Set up the SQL sensor from config entry.""" - db_url: str = resolve_db_url(hass, entry.options.get(CONF_DB_URL)) - name: str = entry.options[CONF_NAME] + db_url: str = resolve_db_url(hass, entry.data.get(CONF_DB_URL)) + name: str = entry.title query_str: str = entry.options[CONF_QUERY] - template: str | None = entry.options.get(CONF_VALUE_TEMPLATE) + template: str | None = entry.options[CONF_ADVANCED_OPTIONS].get(CONF_VALUE_TEMPLATE) column_name: str = entry.options[CONF_COLUMN_NAME] value_template: ValueTemplate | None = None @@ -128,9 +128,9 @@ async def async_setup_entry( name_template = Template(name, hass) trigger_entity_config = {CONF_NAME: name_template, CONF_UNIQUE_ID: entry.entry_id} for key in TRIGGER_ENTITY_OPTIONS: - if key not in entry.options: + if key not in entry.options[CONF_ADVANCED_OPTIONS]: continue - trigger_entity_config[key] = entry.options[key] + trigger_entity_config[key] = entry.options[CONF_ADVANCED_OPTIONS][key] await async_setup_sensor( hass, diff --git a/homeassistant/components/sql/strings.json b/homeassistant/components/sql/strings.json index cbc0deda96a37d..a70a9812657fc1 100644 --- a/homeassistant/components/sql/strings.json +++ b/homeassistant/components/sql/strings.json @@ -14,23 +14,39 @@ "user": { "data": { "db_url": "Database URL", - "name": "[%key:common::config_flow::data::name%]", - "query": "Select query", - "column": "Column", - "unit_of_measurement": "Unit of measurement", - "value_template": "Value template", - "device_class": "Device class", - "state_class": "State class" + "name": "[%key:common::config_flow::data::name%]" }, "data_description": { "db_url": "Leave empty to use Home Assistant Recorder database", - "name": "Name that will be used for config entry and also the sensor", + "name": "Name that will be used for config entry and also the sensor" + } + }, + "options": { + "data": { + "query": "Select query", + "column": "Column" + }, + "data_description": { "query": "Query to run, needs to start with 'SELECT'", - "column": "Column for returned query to present as state", - "unit_of_measurement": "The unit of measurement for the sensor (optional)", - "value_template": "Template to extract a value from the payload (optional)", - "device_class": "The type/class of the sensor to set the icon in the frontend", - "state_class": "The state class of the sensor" + "column": "Column for returned query to present as state" + }, + "sections": { + "advanced_options": { + "name": "Advanced options", + "description": "Provide additional configuration to the sensor", + "data": { + "unit_of_measurement": "Unit of measurement", + "value_template": "Value template", + "device_class": "Device class", + "state_class": "State class" + }, + "data_description": { + "unit_of_measurement": "The unit of measurement for the sensor (optional)", + "value_template": "Template to extract a value from the payload (optional)", + "device_class": "The type/class of the sensor to set the icon in the frontend", + "state_class": "The state class of the sensor" + } + } } } } @@ -39,24 +55,30 @@ "step": { "init": { "data": { - "db_url": "[%key:component::sql::config::step::user::data::db_url%]", - "name": "[%key:common::config_flow::data::name%]", - "query": "[%key:component::sql::config::step::user::data::query%]", - "column": "[%key:component::sql::config::step::user::data::column%]", - "unit_of_measurement": "[%key:component::sql::config::step::user::data::unit_of_measurement%]", - "value_template": "[%key:component::sql::config::step::user::data::value_template%]", - "device_class": "[%key:component::sql::config::step::user::data::device_class%]", - "state_class": "[%key:component::sql::config::step::user::data::state_class%]" + "query": "[%key:component::sql::config::step::options::data::query%]", + "column": "[%key:component::sql::config::step::options::data::column%]" }, "data_description": { - "db_url": "[%key:component::sql::config::step::user::data_description::db_url%]", - "name": "[%key:component::sql::config::step::user::data_description::name%]", - "query": "[%key:component::sql::config::step::user::data_description::query%]", - "column": "[%key:component::sql::config::step::user::data_description::column%]", - "unit_of_measurement": "[%key:component::sql::config::step::user::data_description::unit_of_measurement%]", - "value_template": "[%key:component::sql::config::step::user::data_description::value_template%]", - "device_class": "[%key:component::sql::config::step::user::data_description::device_class%]", - "state_class": "[%key:component::sql::config::step::user::data_description::state_class%]" + "query": "[%key:component::sql::config::step::options::data_description::query%]", + "column": "[%key:component::sql::config::step::options::data_description::column%]" + }, + "sections": { + "advanced_options": { + "name": "[%key:component::sql::config::step::options::sections::advanced_options::name%]", + "description": "[%key:component::sql::config::step::options::sections::advanced_options::name%]", + "data": { + "unit_of_measurement": "[%key:component::sql::config::step::options::sections::advanced_options::data::unit_of_measurement%]", + "value_template": "[%key:component::sql::config::step::options::sections::advanced_options::data::value_template%]", + "device_class": "[%key:component::sql::config::step::options::sections::advanced_options::data::device_class%]", + "state_class": "[%key:component::sql::config::step::options::sections::advanced_options::data::state_class%]" + }, + "data_description": { + "unit_of_measurement": "[%key:component::sql::config::step::options::sections::advanced_options::data_description::unit_of_measurement%]", + "value_template": "[%key:component::sql::config::step::options::sections::advanced_options::data_description::value_template%]", + "device_class": "[%key:component::sql::config::step::options::sections::advanced_options::data_description::device_class%]", + "state_class": "[%key:component::sql::config::step::options::sections::advanced_options::data_description::state_class%]" + } + } } } }, diff --git a/tests/components/sql/__init__.py b/tests/components/sql/__init__.py index 5f91cba1d9412d..6afc0329e32a30 100644 --- a/tests/components/sql/__init__.py +++ b/tests/components/sql/__init__.py @@ -10,7 +10,12 @@ SensorDeviceClass, SensorStateClass, ) -from homeassistant.components.sql.const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN +from homeassistant.components.sql.const import ( + CONF_ADVANCED_OPTIONS, + CONF_COLUMN_NAME, + CONF_QUERY, + DOMAIN, +) from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_DEVICE_CLASS, @@ -30,140 +35,167 @@ from tests.common import MockConfigEntry ENTRY_CONFIG = { - CONF_NAME: "Get Value", CONF_QUERY: "SELECT 5 as value", CONF_COLUMN_NAME: "value", - CONF_UNIT_OF_MEASUREMENT: "MiB", - CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, - CONF_STATE_CLASS: SensorStateClass.TOTAL, + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, + }, } ENTRY_CONFIG_WITH_VALUE_TEMPLATE = { - CONF_NAME: "Get Value", CONF_QUERY: "SELECT 5 as value", CONF_COLUMN_NAME: "value", - CONF_UNIT_OF_MEASUREMENT: "MiB", - CONF_VALUE_TEMPLATE: "{{ value }}", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_VALUE_TEMPLATE: "{{ value }}", + }, } ENTRY_CONFIG_INVALID_QUERY = { - CONF_NAME: "Get Value", CONF_QUERY: "SELECT 5 FROM as value", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_INVALID_QUERY_2 = { - CONF_NAME: "Get Value", CONF_QUERY: "SELECT5 FROM as value", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_INVALID_QUERY_3 = { - CONF_NAME: "Get Value", CONF_QUERY: ";;", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_INVALID_QUERY_OPT = { CONF_QUERY: "SELECT 5 FROM as value", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_INVALID_QUERY_2_OPT = { CONF_QUERY: "SELECT5 FROM as value", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_INVALID_QUERY_3_OPT = { CONF_QUERY: ";;", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_QUERY_READ_ONLY_CTE = { - CONF_NAME: "Get Value", CONF_QUERY: "WITH test AS (SELECT 1 AS row_num, 10 AS state) SELECT state FROM test WHERE row_num = 1 LIMIT 1;", CONF_COLUMN_NAME: "state", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_QUERY_NO_READ_ONLY = { - CONF_NAME: "Get Value", CONF_QUERY: "UPDATE states SET state = 999999 WHERE state_id = 11125", CONF_COLUMN_NAME: "state", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE = { - CONF_NAME: "Get Value", CONF_QUERY: "WITH test AS (SELECT state FROM states) UPDATE states SET states.state = test.state;", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_QUERY_READ_ONLY_CTE_OPT = { CONF_QUERY: "WITH test AS (SELECT 1 AS row_num, 10 AS state) SELECT state FROM test WHERE row_num = 1 LIMIT 1;", CONF_COLUMN_NAME: "state", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_QUERY_NO_READ_ONLY_OPT = { CONF_QUERY: "UPDATE 5 as value", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE_OPT = { CONF_QUERY: "WITH test AS (SELECT state FROM states) UPDATE states SET states.state = test.state;", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_MULTIPLE_QUERIES = { - CONF_NAME: "Get Value", CONF_QUERY: "SELECT 5 as state; UPDATE states SET state = 10;", CONF_COLUMN_NAME: "state", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_MULTIPLE_QUERIES_OPT = { CONF_QUERY: "SELECT 5 as state; UPDATE states SET state = 10;", CONF_COLUMN_NAME: "state", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_INVALID_COLUMN_NAME = { - CONF_NAME: "Get Value", CONF_QUERY: "SELECT 5 as value", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_INVALID_COLUMN_NAME_OPT = { CONF_QUERY: "SELECT 5 as value", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_NO_RESULTS = { - CONF_NAME: "Get Value", CONF_QUERY: "SELECT kalle as value from no_table;", CONF_COLUMN_NAME: "value", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } YAML_CONFIG = { @@ -260,22 +292,29 @@ } -async def init_integration( +async def init_integration( # pylint: disable=dangerous-default-value hass: HomeAssistant, - config: dict[str, Any] | None = None, + *, + title: str = "Select value SQL query", + config: dict[str, Any] = {}, + options: dict[str, Any] | None = None, entry_id: str = "1", source: str = SOURCE_USER, ) -> MockConfigEntry: """Set up the SQL integration in Home Assistant.""" - if not config: - config = ENTRY_CONFIG + if not options: + options = ENTRY_CONFIG + if CONF_ADVANCED_OPTIONS not in options: + options[CONF_ADVANCED_OPTIONS] = {} config_entry = MockConfigEntry( + title=title, domain=DOMAIN, source=source, - data={}, - options=config, + data=config, + options=options, entry_id=entry_id, + version=2, ) config_entry.add_to_hass(hass) diff --git a/tests/components/sql/conftest.py b/tests/components/sql/conftest.py new file mode 100644 index 00000000000000..9d18a7ddd79e09 --- /dev/null +++ b/tests/components/sql/conftest.py @@ -0,0 +1,17 @@ +"""Fixtures for the SQL integration.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.sql.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/sql/test_config_flow.py b/tests/components/sql/test_config_flow.py index 3f2400c0a323a3..863e87b5eae9af 100644 --- a/tests/components/sql/test_config_flow.py +++ b/tests/components/sql/test_config_flow.py @@ -3,14 +3,31 @@ from __future__ import annotations from pathlib import Path +from typing import Any from unittest.mock import patch +import pytest from sqlalchemy.exc import SQLAlchemyError from homeassistant import config_entries -from homeassistant.components.recorder import Recorder -from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass -from homeassistant.components.sql.const import DOMAIN +from homeassistant.components.recorder import CONF_DB_URL +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.components.sql.const import ( + CONF_ADVANCED_OPTIONS, + CONF_COLUMN_NAME, + CONF_QUERY, + DOMAIN, +) +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_NAME, + CONF_UNIT_OF_MEASUREMENT, + CONF_VALUE_TEMPLATE, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -36,8 +53,25 @@ from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry", "recorder_mock") + +DATA_CONFIG = {CONF_NAME: "Get Value"} +DATA_CONFIG_DB = {CONF_NAME: "Get Value", CONF_DB_URL: "sqlite://"} +OPTIONS_DATA_CONFIG = {} -async def test_form(recorder_mock: Recorder, hass: HomeAssistant) -> None: + +@pytest.mark.parametrize( + ("data_config", "result_config"), + [ + (DATA_CONFIG, OPTIONS_DATA_CONFIG), + (DATA_CONFIG_DB, OPTIONS_DATA_CONFIG), + ], +) +async def test_form_simple( + hass: HomeAssistant, + data_config: dict[str, Any], + result_config: dict[str, Any], +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -46,32 +80,33 @@ async def test_form(recorder_mock: Recorder, hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - ENTRY_CONFIG, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Get Value" - assert result2["options"] == { - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", - "device_class": SensorDeviceClass.DATA_SIZE, - "state_class": SensorStateClass.TOTAL, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + data_config, + ) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + ENTRY_CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Get Value" + assert result["data"] == result_config + assert result["options"] == { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, + }, } - assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_with_value_template( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +async def test_form_with_value_template(hass: HomeAssistant) -> None: """Test for with value template.""" result = await hass.config_entries.flow.async_init( @@ -80,208 +115,218 @@ async def test_form_with_value_template( assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - ENTRY_CONFIG_WITH_VALUE_TEMPLATE, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Get Value" - assert result2["options"] == { - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", - "value_template": "{{ value }}", + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + DATA_CONFIG, + ) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + ENTRY_CONFIG_WITH_VALUE_TEMPLATE, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Get Value" + assert result["data"] == {} + assert result["options"] == { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_VALUE_TEMPLATE: "{{ value }}", + }, } - assert len(mock_setup_entry.mock_calls) == 1 -async def test_flow_fails_db_url(recorder_mock: Recorder, hass: HomeAssistant) -> None: +async def test_flow_fails_db_url(hass: HomeAssistant) -> None: """Test config flow fails incorrect db url.""" - result4 = await hass.config_entries.flow.async_init( + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result4["type"] is FlowResultType.FORM - assert result4["step_id"] == config_entries.SOURCE_USER + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == config_entries.SOURCE_USER with patch( "homeassistant.components.sql.config_flow.sqlalchemy.create_engine", side_effect=SQLAlchemyError("error_message"), ): - result4 = await hass.config_entries.flow.async_configure( - result4["flow_id"], - user_input=ENTRY_CONFIG, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=DATA_CONFIG, ) - assert result4["errors"] == {"db_url": "db_url_invalid"} + assert result["errors"] == {CONF_DB_URL: "db_url_invalid"} -async def test_flow_fails_invalid_query( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +async def test_flow_fails_invalid_query(hass: HomeAssistant) -> None: """Test config flow fails incorrect db url.""" - result4 = await hass.config_entries.flow.async_init( + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result4["type"] is FlowResultType.FORM - assert result4["step_id"] == config_entries.SOURCE_USER + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == config_entries.SOURCE_USER - result5 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=DATA_CONFIG, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG_INVALID_QUERY, ) - assert result5["type"] is FlowResultType.FORM - assert result5["errors"] == { - "query": "query_invalid", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_invalid", } - result6 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG_INVALID_QUERY_2, ) - assert result6["type"] is FlowResultType.FORM - assert result6["errors"] == { - "query": "query_invalid", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_invalid", } - result6 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG_INVALID_QUERY_3, ) - assert result6["type"] is FlowResultType.FORM - assert result6["errors"] == { - "query": "query_invalid", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_invalid", } - result5 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG_QUERY_NO_READ_ONLY, ) - assert result5["type"] is FlowResultType.FORM - assert result5["errors"] == { - "query": "query_no_read_only", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_no_read_only", } - result6 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE, ) - assert result6["type"] is FlowResultType.FORM - assert result6["errors"] == { - "query": "query_no_read_only", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_no_read_only", } - result6 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG_MULTIPLE_QUERIES, ) - assert result6["type"] is FlowResultType.FORM - assert result6["errors"] == { - "query": "multiple_queries", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "multiple_queries", } - result5 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG_NO_RESULTS, ) - assert result5["type"] is FlowResultType.FORM - assert result5["errors"] == { - "query": "query_invalid", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_invalid", } - result5 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG, ) - assert result5["type"] is FlowResultType.CREATE_ENTRY - assert result5["title"] == "Get Value" - assert result5["options"] == { - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", - "device_class": SensorDeviceClass.DATA_SIZE, - "state_class": SensorStateClass.TOTAL, + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Get Value" + assert result["data"] == {} + assert result["options"] == { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, + }, } -async def test_flow_fails_invalid_column_name( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +async def test_flow_fails_invalid_column_name(hass: HomeAssistant) -> None: """Test config flow fails invalid column name.""" - result4 = await hass.config_entries.flow.async_init( + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result4["type"] is FlowResultType.FORM - assert result4["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" - result5 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=DATA_CONFIG, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG_INVALID_COLUMN_NAME, ) - assert result5["type"] is FlowResultType.FORM - assert result5["errors"] == { - "column": "column_invalid", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_COLUMN_NAME: "column_invalid", } - result5 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG, ) - assert result5["type"] is FlowResultType.CREATE_ENTRY - assert result5["title"] == "Get Value" - assert result5["options"] == { - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", - "device_class": SensorDeviceClass.DATA_SIZE, - "state_class": SensorStateClass.TOTAL, + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Get Value" + assert result["data"] == {} + assert result["options"] == { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, + }, } -async def test_options_flow(recorder_mock: Recorder, hass: HomeAssistant) -> None: +async def test_options_flow(hass: HomeAssistant) -> None: """Test options config flow.""" entry = MockConfigEntry( domain=DOMAIN, - data={}, + data=OPTIONS_DATA_CONFIG, options={ - "db_url": "sqlite://", - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", - "device_class": SensorDeviceClass.DATA_SIZE, - "state_class": SensorStateClass.TOTAL, + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, + }, }, + version=2, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() result = await hass.config_entries.options.async_init(entry.entry_id) @@ -291,41 +336,43 @@ async def test_options_flow(recorder_mock: Recorder, hass: HomeAssistant) -> Non result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - "db_url": "sqlite://", - "query": "SELECT 5 as size", - "column": "size", - "unit_of_measurement": "MiB", - "value_template": "{{ value }}", - "device_class": SensorDeviceClass.DATA_SIZE, - "state_class": SensorStateClass.TOTAL, + CONF_QUERY: "SELECT 5 as size", + CONF_COLUMN_NAME: "size", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_VALUE_TEMPLATE: "{{ value }}", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, + }, }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { - "name": "Get Value", - "query": "SELECT 5 as size", - "column": "size", - "unit_of_measurement": "MiB", - "value_template": "{{ value }}", - "device_class": SensorDeviceClass.DATA_SIZE, - "state_class": SensorStateClass.TOTAL, + CONF_QUERY: "SELECT 5 as size", + CONF_COLUMN_NAME: "size", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_VALUE_TEMPLATE: "{{ value }}", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, + }, } -async def test_options_flow_name_previously_removed( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +async def test_options_flow_name_previously_removed(hass: HomeAssistant) -> None: """Test options config flow where the name was missing.""" entry = MockConfigEntry( domain=DOMAIN, - data={}, + data=OPTIONS_DATA_CONFIG, options={ - "db_url": "sqlite://", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, + version=2, title="Get Value Title", ) entry.add_to_hass(hass) @@ -338,54 +385,46 @@ async def test_options_flow_name_previously_removed( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - "db_url": "sqlite://", - "query": "SELECT 5 as size", - "column": "size", - "unit_of_measurement": "MiB", + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_QUERY: "SELECT 5 as size", + CONF_COLUMN_NAME: "size", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", }, - ) - await hass.async_block_till_done() + }, + ) + await hass.async_block_till_done() - assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { - "name": "Get Value Title", - "query": "SELECT 5 as size", - "column": "size", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as size", + CONF_COLUMN_NAME: "size", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } -async def test_options_flow_fails_db_url( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +async def test_options_flow_fails_db_url(hass: HomeAssistant) -> None: """Test options flow fails incorrect db url.""" entry = MockConfigEntry( domain=DOMAIN, - data={}, + data=OPTIONS_DATA_CONFIG, options={ - "db_url": "sqlite://", - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, + version=2, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() result = await hass.config_entries.options.async_init(entry.entry_id) @@ -393,233 +432,221 @@ async def test_options_flow_fails_db_url( "homeassistant.components.sql.config_flow.sqlalchemy.create_engine", side_effect=SQLAlchemyError("error_message"), ): - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - "db_url": "sqlite://", - "query": "SELECT 5 as size", - "column": "size", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as size", + CONF_COLUMN_NAME: "size", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, ) - assert result2["errors"] == {"db_url": "db_url_invalid"} + assert result["errors"] == {CONF_DB_URL: "db_url_invalid"} -async def test_options_flow_fails_invalid_query( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +async def test_options_flow_fails_invalid_query(hass: HomeAssistant) -> None: """Test options flow fails incorrect query and template.""" entry = MockConfigEntry( domain=DOMAIN, - data={}, + data=OPTIONS_DATA_CONFIG, options={ - "db_url": "sqlite://", - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, + version=2, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() result = await hass.config_entries.options.async_init(entry.entry_id) - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=ENTRY_CONFIG_INVALID_QUERY_OPT, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == { - "query": "query_invalid", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_invalid", } - result3 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=ENTRY_CONFIG_INVALID_QUERY_2_OPT, ) - assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == { - "query": "query_invalid", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_invalid", } - result3 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=ENTRY_CONFIG_INVALID_QUERY_3_OPT, ) - assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == { - "query": "query_invalid", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_invalid", } - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=ENTRY_CONFIG_QUERY_NO_READ_ONLY_OPT, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == { - "query": "query_no_read_only", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_no_read_only", } - result3 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE_OPT, ) - assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == { - "query": "query_no_read_only", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_no_read_only", } - result3 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=ENTRY_CONFIG_MULTIPLE_QUERIES_OPT, ) - assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == { - "query": "multiple_queries", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "multiple_queries", } - result4 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - "db_url": "sqlite://", - "query": "SELECT 5 as size", - "column": "size", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as size", + CONF_COLUMN_NAME: "size", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, ) - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["data"] == { - "name": "Get Value", - "query": "SELECT 5 as size", - "column": "size", - "unit_of_measurement": "MiB", + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_QUERY: "SELECT 5 as size", + CONF_COLUMN_NAME: "size", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } -async def test_options_flow_fails_invalid_column_name( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +async def test_options_flow_fails_invalid_column_name(hass: HomeAssistant) -> None: """Test options flow fails invalid column name.""" entry = MockConfigEntry( domain=DOMAIN, - data={}, + data=OPTIONS_DATA_CONFIG, options={ - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, + version=2, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() result = await hass.config_entries.options.async_init(entry.entry_id) - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=ENTRY_CONFIG_INVALID_COLUMN_NAME_OPT, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == { - "column": "column_invalid", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_COLUMN_NAME: "column_invalid", } - result4 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, ) - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["data"] == { - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } -async def test_options_flow_db_url_empty( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +async def test_options_flow_db_url_empty(hass: HomeAssistant) -> None: """Test options config flow with leaving db_url empty.""" entry = MockConfigEntry( domain=DOMAIN, - data={}, + data=OPTIONS_DATA_CONFIG, options={ - "db_url": "sqlite://", - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, + version=2, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - with ( - patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ), - ): - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - "query": "SELECT 5 as size", - "column": "size", - "unit_of_measurement": "MiB", + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_QUERY: "SELECT 5 as size", + CONF_COLUMN_NAME: "size", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", }, - ) - await hass.async_block_till_done() + }, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { - "name": "Get Value", - "query": "SELECT 5 as size", - "column": "size", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as size", + CONF_COLUMN_NAME: "size", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } async def test_full_flow_not_recorder_db( - recorder_mock: Recorder, hass: HomeAssistant, tmp_path: Path, ) -> None: @@ -632,30 +659,31 @@ async def test_full_flow_not_recorder_db( db_path = tmp_path / "db.db" db_path_str = f"sqlite:///{db_path}" - with ( - patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "db_url": db_path_str, - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Get Value" - assert result2["options"] == { - "name": "Get Value", - "db_url": db_path_str, - "query": "SELECT 5 as value", - "column": "value", + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_DB_URL: db_path_str, + CONF_NAME: "Get Value", + }, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: {}, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Get Value" + assert result["data"] == {CONF_DB_URL: db_path_str} + assert result["options"] == { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: {}, } entry = hass.config_entries.async_entries(DOMAIN)[0] @@ -665,76 +693,42 @@ async def test_full_flow_not_recorder_db( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - with ( - patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ), - ): - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - "query": "SELECT 5 as value", - "db_url": db_path_str, - "column": "value", - "unit_of_measurement": "MiB", - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == { - "name": "Get Value", - "db_url": db_path_str, - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", - } - - # Need to test same again to mitigate issue with db_url removal - result = await hass.config_entries.options.async_init(entry.entry_id) - result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - "query": "SELECT 5 as value", - "db_url": db_path_str, - "column": "value", - "unit_of_measurement": "MB", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { - "name": "Get Value", - "db_url": db_path_str, - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MB", - } - - assert entry.options == { - "name": "Get Value", - "db_url": db_path_str, - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MB", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } -async def test_device_state_class(recorder_mock: Recorder, hass: HomeAssistant) -> None: +async def test_device_state_class(hass: HomeAssistant) -> None: """Test we get the form.""" entry = MockConfigEntry( domain=DOMAIN, - data={}, + data=OPTIONS_DATA_CONFIG, options={ - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, + version=2, ) entry.add_to_hass(hass) @@ -742,56 +736,54 @@ async def test_device_state_class(recorder_mock: Recorder, hass: HomeAssistant) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ): - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", - "device_class": SensorDeviceClass.DATA_SIZE, - "state_class": SensorStateClass.TOTAL, + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["data"] == { - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", - "device_class": SensorDeviceClass.DATA_SIZE, - "state_class": SensorStateClass.TOTAL, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, + }, } result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ): - result3 = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", }, - ) - await hass.async_block_till_done() - - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert "device_class" not in result3["data"] - assert "state_class" not in result3["data"] - assert result3["data"] == { - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert CONF_DEVICE_CLASS not in result["data"] + assert CONF_STATE_CLASS not in result["data"] + assert result["data"] == { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } diff --git a/tests/components/sql/test_init.py b/tests/components/sql/test_init.py index 409ebca27c0ae3..7236b7212d3b1d 100644 --- a/tests/components/sql/test_init.py +++ b/tests/components/sql/test_init.py @@ -7,16 +7,33 @@ import pytest import voluptuous as vol -from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.util import get_instance +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 DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.components.sql.const import ( + CONF_ADVANCED_OPTIONS, + CONF_COLUMN_NAME, + CONF_QUERY, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_NAME, + CONF_UNIT_OF_MEASUREMENT, + CONF_VALUE_TEMPLATE, +) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from . import YAML_CONFIG_INVALID, YAML_CONFIG_NO_DB, init_integration +from tests.common import MockConfigEntry + async def test_setup_entry(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test setup entry.""" @@ -86,39 +103,71 @@ async def test_multiple_queries(hass: HomeAssistant) -> None: validate_sql_select("SELECT 5 as value; UPDATE states SET state = 10;") -async def test_remove_configured_db_url_if_not_needed_when_not_needed( - recorder_mock: Recorder, - hass: HomeAssistant, +async def test_migration_from_future( + recorder_mock: Recorder, hass: HomeAssistant ) -> None: - """Test configured db_url is replaced with None if matching the recorder db.""" - recorder_db_url = get_instance(hass).db_url - - config = { - "db_url": recorder_db_url, - "query": "SELECT 5 as value", - "column": "value", - "name": "count_tables", - } - - config_entry = await init_integration(hass, config) + """Test migration from future version fails.""" + config_entry = MockConfigEntry( + title="Test future", + domain=DOMAIN, + source=SOURCE_USER, + data={}, + options={ + CONF_QUERY: "SELECT 5.01 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: {}, + }, + entry_id="1", + version=3, + ) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() - assert config_entry.options.get("db_url") is None + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR -async def test_remove_configured_db_url_if_not_needed_when_needed( - recorder_mock: Recorder, - hass: HomeAssistant, +async def test_migration_from_v1_to_v2( + recorder_mock: Recorder, hass: HomeAssistant ) -> None: - """Test configured db_url is not replaced if it differs from the recorder db.""" - db_url = "mssql://" - - config = { - "db_url": db_url, - "query": "SELECT 5 as value", - "column": "value", - "name": "count_tables", - } + """Test migration from version 1 to 2.""" + config_entry = MockConfigEntry( + title="Test migration", + domain=DOMAIN, + source=SOURCE_USER, + data={}, + options={ + CONF_DB_URL: "sqlite://", + CONF_NAME: "Test migration", + CONF_QUERY: "SELECT 5.01 as value", + CONF_COLUMN_NAME: "value", + CONF_VALUE_TEMPLATE: "{{ value | int }}", + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, + }, + entry_id="1", + version=1, + ) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() - config_entry = await init_integration(hass, config) + assert config_entry.state is ConfigEntryState.LOADED + + assert config_entry.data == {} + assert config_entry.options == { + CONF_QUERY: "SELECT 5.01 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_VALUE_TEMPLATE: "{{ value | int }}", + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, + }, + } - assert config_entry.options.get("db_url") == db_url + state = hass.states.get("sensor.test_migration") + assert state.state == "5" diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index 354840c518eaf8..aa14be2f643f0d 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -12,14 +12,27 @@ import pytest from sqlalchemy.exc import SQLAlchemyError -from homeassistant.components.recorder import Recorder -from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass -from homeassistant.components.sql.const import CONF_QUERY, DOMAIN +from homeassistant.components.recorder import CONF_DB_URL, Recorder +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.components.sql.const import ( + CONF_ADVANCED_OPTIONS, + CONF_COLUMN_NAME, + CONF_QUERY, + DOMAIN, +) from homeassistant.components.sql.sensor import _generate_lambda_stmt from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_ICON, + CONF_NAME, CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, + CONF_VALUE_TEMPLATE, STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfInformation, @@ -37,7 +50,6 @@ YAML_CONFIG_FULL_TABLE_SCAN, YAML_CONFIG_FULL_TABLE_SCAN_NO_UNIQUE_ID, YAML_CONFIG_FULL_TABLE_SCAN_WITH_MULTIPLE_COLUMNS, - YAML_CONFIG_WITH_VIEW_THAT_CONTAINS_ENTITY_ID, init_integration, ) @@ -46,14 +58,11 @@ async def test_query_basic(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test the SQL sensor.""" - config = { - "db_url": "sqlite://", - "query": "SELECT 5 as value", - "column": "value", - "name": "Select value SQL query", - "unique_id": "very_unique_id", + options = { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", } - await init_integration(hass, config) + await init_integration(hass, title="Select value SQL query", options=options) state = hass.states.get("sensor.select_value_sql_query") assert state.state == "5" @@ -62,14 +71,11 @@ async def test_query_basic(recorder_mock: Recorder, hass: HomeAssistant) -> None async def test_query_cte(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test the SQL sensor with CTE.""" - config = { - "db_url": "sqlite://", - "query": "WITH test AS (SELECT 1 AS row_num, 10 AS state) SELECT state FROM test WHERE row_num = 1 LIMIT 1;", - "column": "state", - "name": "Select value SQL query CTE", - "unique_id": "very_unique_id", + options = { + CONF_QUERY: "WITH test AS (SELECT 1 AS row_num, 10 AS state) SELECT state FROM test WHERE row_num = 1 LIMIT 1;", + CONF_COLUMN_NAME: "state", } - await init_integration(hass, config) + await init_integration(hass, title="Select value SQL query CTE", options=options) state = hass.states.get("sensor.select_value_sql_query_cte") assert state.state == "10" @@ -80,31 +86,39 @@ async def test_query_value_template( recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test the SQL sensor.""" - config = { - "db_url": "sqlite://", - "query": "SELECT 5.01 as value", - "column": "value", - "name": "count_tables", - "value_template": "{{ value | int }}", + options = { + CONF_QUERY: "SELECT 5.01 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_VALUE_TEMPLATE: "{{ value | int }}", + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, + }, } - await init_integration(hass, config) + await init_integration(hass, title="count_tables", options=options) state = hass.states.get("sensor.count_tables") assert state.state == "5" + assert state.attributes == { + "device_class": "data_size", + "friendly_name": "count_tables", + "state_class": "measurement", + "unit_of_measurement": "MiB", + "value": 5.01, + } async def test_query_value_template_invalid( recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test the SQL sensor.""" - config = { - "db_url": "sqlite://", - "query": "SELECT 5.01 as value", - "column": "value", - "name": "count_tables", - "value_template": "{{ value | dontwork }}", + options = { + CONF_QUERY: "SELECT 5.01 as value", + CONF_COLUMN_NAME: "value", + CONF_VALUE_TEMPLATE: "{{ value | dontwork }}", } - await init_integration(hass, config) + await init_integration(hass, title="count_tables", options=options) state = hass.states.get("sensor.count_tables") assert state.state == "5.01" @@ -112,13 +126,11 @@ async def test_query_value_template_invalid( async def test_query_limit(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test the SQL sensor with a query containing 'LIMIT' in lowercase.""" - config = { - "db_url": "sqlite://", - "query": "SELECT 5 as value limit 1", - "column": "value", - "name": "Select value SQL query", + options = { + CONF_QUERY: "SELECT 5 as value limit 1", + CONF_COLUMN_NAME: "value", } - await init_integration(hass, config) + await init_integration(hass, options=options) state = hass.states.get("sensor.select_value_sql_query") assert state.state == "5" @@ -129,13 +141,11 @@ async def test_query_no_value( recorder_mock: Recorder, hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test the SQL sensor with a query that returns no value.""" - config = { - "db_url": "sqlite://", - "query": "SELECT 5 as value where 1=2", - "column": "value", - "name": "count_tables", + options = { + CONF_QUERY: "SELECT 5 as value where 1=2", + CONF_COLUMN_NAME: "value", } - await init_integration(hass, config) + await init_integration(hass, title="count_tables", options=options) state = hass.states.get("sensor.count_tables") assert state.state == STATE_UNKNOWN @@ -163,13 +173,13 @@ def make_test_db(): await hass.async_add_executor_job(make_test_db) - config = { - "db_url": db_path_str, - "query": "SELECT value from users", - "column": "value", - "name": "count_users", + config = {CONF_DB_URL: db_path_str} + options = { + CONF_QUERY: "SELECT value from users", + CONF_COLUMN_NAME: "value", + CONF_NAME: "count_users", } - await init_integration(hass, config) + await init_integration(hass, title="count_users", options=options, config=config) state = hass.states.get("sensor.count_users") assert state.state == STATE_UNKNOWN @@ -203,17 +213,17 @@ async def test_invalid_url_setup( ) -> None: """Test invalid db url with redacted credentials.""" config = { - "db_url": url, - "query": "SELECT 5 as value", - "column": "value", - "name": "count_tables", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", } entry = MockConfigEntry( + title="count_tables", domain=DOMAIN, source=SOURCE_USER, - data={}, + data={CONF_DB_URL: url}, options=config, entry_id="1", + version=2, ) entry.add_to_hass(hass) @@ -237,11 +247,9 @@ async def test_invalid_url_on_update( caplog: pytest.LogCaptureFixture, ) -> None: """Test invalid db url with redacted credentials on retry.""" - config = { - "db_url": "sqlite://", - "query": "SELECT 5 as value", - "column": "value", - "name": "count_tables", + options = { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", } class MockSession: @@ -255,7 +263,7 @@ def execute(self, query: Any) -> None: "homeassistant.components.sql.sensor.scoped_session", return_value=MockSession, ): - await init_integration(hass, config) + await init_integration(hass, title="count_tables", options=options) async_fire_time_changed( hass, dt_util.utcnow() + timedelta(minutes=1), @@ -343,12 +351,12 @@ async def test_config_from_old_yaml( config = { "sensor": { "platform": "sql", - "db_url": "sqlite://", + CONF_DB_URL: "sqlite://", "queries": [ { - "name": "count_tables", - "query": "SELECT 5 as value", - "column": "value", + CONF_NAME: "count_tables", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", } ], } @@ -386,10 +394,10 @@ async def test_invalid_url_setup_from_yaml( """Test invalid db url with redacted credentials from yaml setup.""" config = { "sql": { - "db_url": url, - "query": "SELECT 5 as value", - "column": "value", - "name": "count_tables", + CONF_DB_URL: url, + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_NAME: "count_tables", } } @@ -417,9 +425,9 @@ async def test_attributes_from_yaml_setup( state = hass.states.get("sensor.get_value") assert state.state == "5" - assert state.attributes["device_class"] == SensorDeviceClass.DATA_SIZE - assert state.attributes["state_class"] == SensorStateClass.MEASUREMENT - assert state.attributes["unit_of_measurement"] == UnitOfInformation.MEBIBYTES + assert state.attributes[CONF_DEVICE_CLASS] == SensorDeviceClass.DATA_SIZE + assert state.attributes[CONF_STATE_CLASS] == SensorStateClass.MEASUREMENT + assert state.attributes[CONF_UNIT_OF_MEASUREMENT] == UnitOfInformation.MEBIBYTES async def test_binary_data_from_yaml_setup( @@ -455,7 +463,7 @@ async def test_issue_when_using_old_query( issue = issue_registry.async_get_issue( DOMAIN, f"entity_id_query_does_full_table_scan_{unique_id}" ) - assert issue.translation_placeholders == {"query": config[CONF_QUERY]} + assert issue.translation_placeholders == {CONF_QUERY: config[CONF_QUERY]} @pytest.mark.parametrize( @@ -486,7 +494,7 @@ async def test_issue_when_using_old_query_without_unique_id( issue = issue_registry.async_get_issue( DOMAIN, f"entity_id_query_does_full_table_scan_{query}" ) - assert issue.translation_placeholders == {"query": query} + assert issue.translation_placeholders == {CONF_QUERY: query} async def test_no_issue_when_view_has_the_text_entity_id_in_it( @@ -498,7 +506,12 @@ async def test_no_issue_when_view_has_the_text_entity_id_in_it( "homeassistant.components.sql.sensor.scoped_session", ): await init_integration( - hass, YAML_CONFIG_WITH_VIEW_THAT_CONTAINS_ENTITY_ID["sql"] + hass, + title="Get entity_id", + options={ + CONF_QUERY: "SELECT value from view_sensor_db_unique_entity_ids;", + CONF_COLUMN_NAME: "value", + }, ) async_fire_time_changed( hass, @@ -516,20 +529,18 @@ async def test_multiple_sensors_using_same_db( recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test multiple sensors using the same db.""" - config = { - "db_url": "sqlite:///", - "query": "SELECT 5 as value", - "column": "value", - "name": "Select value SQL query", + options = { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", } - config2 = { - "db_url": "sqlite:///", - "query": "SELECT 5 as value", - "column": "value", - "name": "Select value SQL query 2", + options2 = { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", } - await init_integration(hass, config) - await init_integration(hass, config2, entry_id="2") + await init_integration(hass, title="Select value SQL query", options=options) + await init_integration( + hass, title="Select value SQL query 2", options=options2, entry_id="2" + ) state = hass.states.get("sensor.select_value_sql_query") assert state.state == "5" @@ -547,13 +558,14 @@ async def test_engine_is_disposed_at_stop( recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test we dispose of the engine at stop.""" - config = { - "db_url": "sqlite:///", - "query": "SELECT 5 as value", - "column": "value", - "name": "Select value SQL query", + config = {CONF_DB_URL: "sqlite:///"} + options = { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", } - await init_integration(hass, config) + await init_integration( + hass, title="Select value SQL query", config=config, options=options + ) state = hass.states.get("sensor.select_value_sql_query") assert state.state == "5" @@ -572,13 +584,15 @@ async def test_attributes_from_entry_config( await init_integration( hass, - config={ - "name": "Get Value - With", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", - "device_class": SensorDeviceClass.DATA_SIZE, - "state_class": SensorStateClass.TOTAL, + title="Get Value - With", + options={ + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, + }, }, entry_id="8693d4782ced4fb1ecca4743f29ab8f1", ) @@ -586,27 +600,29 @@ async def test_attributes_from_entry_config( state = hass.states.get("sensor.get_value_with") assert state.state == "5" assert state.attributes["value"] == 5 - assert state.attributes["unit_of_measurement"] == "MiB" - assert state.attributes["device_class"] == SensorDeviceClass.DATA_SIZE - assert state.attributes["state_class"] == SensorStateClass.TOTAL + assert state.attributes[CONF_UNIT_OF_MEASUREMENT] == "MiB" + assert state.attributes[CONF_DEVICE_CLASS] == SensorDeviceClass.DATA_SIZE + assert state.attributes[CONF_STATE_CLASS] == SensorStateClass.TOTAL await init_integration( hass, - config={ - "name": "Get Value - Without", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + title="Get Value - Without", + options={ + CONF_QUERY: "SELECT 6 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, entry_id="7aec7cd8045fba4778bb0621469e3cd9", ) state = hass.states.get("sensor.get_value_without") - assert state.state == "5" - assert state.attributes["value"] == 5 - assert state.attributes["unit_of_measurement"] == "MiB" - assert "device_class" not in state.attributes - assert "state_class" not in state.attributes + assert state.state == "6" + assert state.attributes["value"] == 6 + assert state.attributes[CONF_UNIT_OF_MEASUREMENT] == "MiB" + assert CONF_DEVICE_CLASS not in state.attributes + assert CONF_STATE_CLASS not in state.attributes async def test_query_recover_from_rollback( @@ -616,14 +632,12 @@ async def test_query_recover_from_rollback( caplog: pytest.LogCaptureFixture, ) -> None: """Test the SQL sensor.""" - config = { - "db_url": "sqlite://", - "query": "SELECT 5 as value", - "column": "value", - "name": "Select value SQL query", - "unique_id": "very_unique_id", + options = { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_UNIQUE_ID: "very_unique_id", } - await init_integration(hass, config) + await init_integration(hass, title="Select value SQL query", options=options) platforms = async_get_platforms(hass, "sql") sql_entity = platforms[0].entities["sensor.select_value_sql_query"] @@ -671,7 +685,7 @@ async def test_availability_blocks_value_template( """Test availability blocks value_template from rendering.""" error = "Error parsing value for sensor.get_value: 'x' is undefined" config = YAML_CONFIG - config["sql"]["value_template"] = "{{ x - 0 }}" + config["sql"][CONF_VALUE_TEMPLATE] = "{{ x - 0 }}" config["sql"]["availability"] = '{{ states("sensor.input1")=="on" }}' hass.states.async_set("sensor.input1", "off") From 42d0415a869588b7822cb631d626944406d2a1ef Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Thu, 11 Sep 2025 13:36:11 +0300 Subject: [PATCH 03/15] Update Shelly Neo water valve device class and units (#152080) --- homeassistant/components/shelly/const.py | 10 ++++++++++ homeassistant/components/shelly/number.py | 6 ++---- homeassistant/components/shelly/sensor.py | 5 ++--- homeassistant/components/shelly/utils.py | 10 ++++++++++ tests/components/shelly/test_sensor.py | 1 + 5 files changed, 25 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index c93b67a56d914e..bfa4718fb2e403 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -30,6 +30,7 @@ from homeassistant.components.number import NumberMode from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import UnitOfVolumeFlowRate DOMAIN: Final = "shelly" @@ -287,6 +288,15 @@ class BLEScannerMode(StrEnum): ROLE_TO_DEVICE_CLASS_MAP = { "current_humidity": SensorDeviceClass.HUMIDITY, "current_temperature": SensorDeviceClass.TEMPERATURE, + "flow_rate": SensorDeviceClass.VOLUME_FLOW_RATE, + "water_pressure": SensorDeviceClass.PRESSURE, + "water_temperature": SensorDeviceClass.TEMPERATURE, +} + +# Mapping for units that require conversion to a Home Assistant recognized unit +# e.g. "m3/min" to "m³/min" +DEVICE_UNIT_MAP = { + "m3/min": UnitOfVolumeFlowRate.CUBIC_METERS_PER_MINUTE, } # We want to check only the first 5 KB of the script if it contains emitEvent() diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index e406d63bdc2a8c..989b30af3992c9 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -40,6 +40,7 @@ get_blu_trv_device_info, get_device_entry_gen, get_virtual_component_ids, + get_virtual_component_unit, ) PARALLEL_UPDATES = 0 @@ -189,10 +190,7 @@ async def async_set_native_value(self, value: float) -> None: config["meta"]["ui"]["view"], NumberMode.BOX ), step_fn=lambda config: config["meta"]["ui"].get("step"), - # If the unit is not set, the device sends an empty string - unit=lambda config: config["meta"]["ui"]["unit"] - if config["meta"]["ui"]["unit"] - else None, + unit=get_virtual_component_unit, method="number_set", ), "valve_position": RpcNumberDescription( diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index bd94ea0c33e44c..2a1478f13077bd 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -61,6 +61,7 @@ get_device_uptime, get_shelly_air_lamp_life, get_virtual_component_ids, + get_virtual_component_unit, is_rpc_wifi_stations_disabled, ) @@ -1376,9 +1377,7 @@ def __init__( "number": RpcSensorDescription( key="number", sub_key="value", - unit=lambda config: config["meta"]["ui"]["unit"] - if config["meta"]["ui"]["unit"] - else None, + unit=get_virtual_component_unit, device_class_fn=lambda config: ROLE_TO_DEVICE_CLASS_MAP.get(config["role"]) if "role" in config else None, diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 2ee960348dd511..a76c27f0eb934e 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -57,6 +57,7 @@ COMPONENT_ID_PATTERN, CONF_COAP_PORT, CONF_GEN, + DEVICE_UNIT_MAP, DEVICES_WITHOUT_FIRMWARE_CHANGELOG, DOMAIN, FIRMWARE_UNSUPPORTED_ISSUE_ID, @@ -653,6 +654,15 @@ def get_virtual_component_ids(config: dict[str, Any], platform: str) -> list[str return ids +def get_virtual_component_unit(config: dict[str, Any]) -> str | None: + """Return the unit of a virtual component. + + If the unit is not set, the device sends an empty string + """ + unit = config["meta"]["ui"]["unit"] + return DEVICE_UNIT_MAP.get(unit, unit) if unit else None + + @callback def async_remove_orphaned_entities( hass: HomeAssistant, diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index 8f021c2d58a8c2..f2d8684985453f 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -1138,6 +1138,7 @@ async def test_rpc_remove_text_virtual_sensor_when_orphaned( ("name", "entity_id", "original_unit", "expected_unit"), [ ("Virtual number sensor", "sensor.test_name_virtual_number_sensor", "W", "W"), + ("Unit map", "sensor.test_name_unit_map", "m3/min", "m³/min"), (None, "sensor.test_name_number_203", "", None), ], ) From 50349e49f1353602b771f5339670abc8aeaba42f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 11 Sep 2025 12:52:58 +0200 Subject: [PATCH 04/15] Register sonos entity services in async_setup (#152047) --- homeassistant/components/sonos/__init__.py | 3 + homeassistant/components/sonos/const.py | 1 + .../components/sonos/media_player.py | 111 +------------- homeassistant/components/sonos/services.py | 143 ++++++++++++++++++ tests/components/sonos/test_media_player.py | 6 +- 5 files changed, 154 insertions(+), 110 deletions(-) create mode 100644 homeassistant/components/sonos/services.py diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index cbce25197b0281..0231fca42dd5eb 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -59,6 +59,7 @@ from .exception import SonosUpdateError from .favorites import SonosFavorites from .helpers import SonosConfigEntry, SonosData, sync_get_visible_zones +from .services import async_setup_services from .speaker import SonosSpeaker _LOGGER = logging.getLogger(__name__) @@ -104,6 +105,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) ) + async_setup_services(hass) + return True diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index ac2e3f50f13659..20e079c901d482 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -194,6 +194,7 @@ SPEECH_DIALOG_LEVEL = "speech_dialog_level" ATTR_DIALOG_LEVEL = "dialog_level" ATTR_DIALOG_LEVEL_ENUM = "dialog_level_enum" +ATTR_QUEUE_POSITION = "queue_position" AVAILABILITY_CHECK_INTERVAL = datetime.timedelta(minutes=1) AVAILABILITY_TIMEOUT = AVAILABILITY_CHECK_INTERVAL.total_seconds() * 4.5 diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 0b30c820da3931..d4ecc5cf05be2d 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -17,7 +17,6 @@ from soco.data_structures import DidlFavorite, DidlMusicTrack from soco.ms_data_structures import MusicServiceItem from sonos_websocket.exception import SonosWebsocketError -import voluptuous as vol from homeassistant.components import media_source, spotify from homeassistant.components.media_player import ( @@ -40,21 +39,16 @@ ) from homeassistant.components.plex import PLEX_URI_SCHEME from homeassistant.components.plex.services import process_plex_payload -from homeassistant.const import ATTR_TIME -from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import ( - config_validation as cv, - entity_platform, - entity_registry as er, - service, -) +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from . import media_browser from .const import ( + ATTR_QUEUE_POSITION, DOMAIN, MEDIA_TYPE_DIRECTORY, MEDIA_TYPES_TO_SONOS, @@ -93,24 +87,6 @@ UPNP_ERRORS_TO_IGNORE = ["701", "711", "712"] ANNOUNCE_NOT_SUPPORTED_ERRORS: list[str] = ["globalError"] -SERVICE_SNAPSHOT = "snapshot" -SERVICE_RESTORE = "restore" -SERVICE_SET_TIMER = "set_sleep_timer" -SERVICE_CLEAR_TIMER = "clear_sleep_timer" -SERVICE_UPDATE_ALARM = "update_alarm" -SERVICE_PLAY_QUEUE = "play_queue" -SERVICE_REMOVE_FROM_QUEUE = "remove_from_queue" -SERVICE_GET_QUEUE = "get_queue" - -ATTR_SLEEP_TIME = "sleep_time" -ATTR_ALARM_ID = "alarm_id" -ATTR_VOLUME = "volume" -ATTR_ENABLED = "enabled" -ATTR_INCLUDE_LINKED_ZONES = "include_linked_zones" -ATTR_MASTER = "master" -ATTR_WITH_GROUP = "with_group" -ATTR_QUEUE_POSITION = "queue_position" - async def async_setup_entry( hass: HomeAssistant, @@ -118,7 +94,6 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sonos from a config entry.""" - platform = entity_platform.async_get_current_platform() @callback def async_create_entities(speaker: SonosSpeaker) -> None: @@ -126,90 +101,10 @@ def async_create_entities(speaker: SonosSpeaker) -> None: _LOGGER.debug("Creating media_player on %s", speaker.zone_name) async_add_entities([SonosMediaPlayerEntity(speaker, config_entry)]) - @service.verify_domain_control(hass, DOMAIN) - async def async_service_handle(service_call: ServiceCall) -> None: - """Handle dispatched services.""" - assert platform is not None - entities = await platform.async_extract_from_service(service_call) - - if not entities: - return - - speakers = [] - for entity in entities: - assert isinstance(entity, SonosMediaPlayerEntity) - speakers.append(entity.speaker) - - if service_call.service == SERVICE_SNAPSHOT: - await SonosSpeaker.snapshot_multi( - hass, config_entry, speakers, service_call.data[ATTR_WITH_GROUP] - ) - elif service_call.service == SERVICE_RESTORE: - await SonosSpeaker.restore_multi( - hass, config_entry, speakers, service_call.data[ATTR_WITH_GROUP] - ) - config_entry.async_on_unload( async_dispatcher_connect(hass, SONOS_CREATE_MEDIA_PLAYER, async_create_entities) ) - join_unjoin_schema = cv.make_entity_service_schema( - {vol.Optional(ATTR_WITH_GROUP, default=True): cv.boolean} - ) - - hass.services.async_register( - DOMAIN, SERVICE_SNAPSHOT, async_service_handle, join_unjoin_schema - ) - - hass.services.async_register( - DOMAIN, SERVICE_RESTORE, async_service_handle, join_unjoin_schema - ) - - platform.async_register_entity_service( - SERVICE_SET_TIMER, - { - vol.Required(ATTR_SLEEP_TIME): vol.All( - vol.Coerce(int), vol.Range(min=0, max=86399) - ) - }, - "set_sleep_timer", - ) - - platform.async_register_entity_service( - SERVICE_CLEAR_TIMER, None, "clear_sleep_timer" - ) - - platform.async_register_entity_service( - SERVICE_UPDATE_ALARM, - { - vol.Required(ATTR_ALARM_ID): cv.positive_int, - vol.Optional(ATTR_TIME): cv.time, - vol.Optional(ATTR_VOLUME): cv.small_float, - vol.Optional(ATTR_ENABLED): cv.boolean, - vol.Optional(ATTR_INCLUDE_LINKED_ZONES): cv.boolean, - }, - "set_alarm", - ) - - platform.async_register_entity_service( - SERVICE_PLAY_QUEUE, - {vol.Optional(ATTR_QUEUE_POSITION): cv.positive_int}, - "play_queue", - ) - - platform.async_register_entity_service( - SERVICE_REMOVE_FROM_QUEUE, - {vol.Optional(ATTR_QUEUE_POSITION): cv.positive_int}, - "remove_from_queue", - ) - - platform.async_register_entity_service( - SERVICE_GET_QUEUE, - None, - "get_queue", - supports_response=SupportsResponse.ONLY, - ) - class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): """Representation of a Sonos entity.""" diff --git a/homeassistant/components/sonos/services.py b/homeassistant/components/sonos/services.py new file mode 100644 index 00000000000000..1f2daee56984cb --- /dev/null +++ b/homeassistant/components/sonos/services.py @@ -0,0 +1,143 @@ +"""Support to interface with Sonos players.""" + +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN +from homeassistant.const import ATTR_TIME +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback +from homeassistant.helpers import config_validation as cv, service +from homeassistant.helpers.entity_platform import DATA_DOMAIN_PLATFORM_ENTITIES + +from .const import ATTR_QUEUE_POSITION, DOMAIN +from .media_player import SonosMediaPlayerEntity +from .speaker import SonosSpeaker + +SERVICE_SNAPSHOT = "snapshot" +SERVICE_RESTORE = "restore" +SERVICE_SET_TIMER = "set_sleep_timer" +SERVICE_CLEAR_TIMER = "clear_sleep_timer" +SERVICE_UPDATE_ALARM = "update_alarm" +SERVICE_PLAY_QUEUE = "play_queue" +SERVICE_REMOVE_FROM_QUEUE = "remove_from_queue" +SERVICE_GET_QUEUE = "get_queue" + +ATTR_SLEEP_TIME = "sleep_time" +ATTR_ALARM_ID = "alarm_id" +ATTR_VOLUME = "volume" +ATTR_ENABLED = "enabled" +ATTR_INCLUDE_LINKED_ZONES = "include_linked_zones" +ATTR_WITH_GROUP = "with_group" + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register Sonos services.""" + + @service.verify_domain_control(hass, DOMAIN) + async def async_service_handle(service_call: ServiceCall) -> None: + """Handle dispatched services.""" + platform_entities = hass.data.get(DATA_DOMAIN_PLATFORM_ENTITIES, {}).get( + (MEDIA_PLAYER_DOMAIN, DOMAIN), {} + ) + + entities = await service.async_extract_entities( + hass, platform_entities.values(), service_call + ) + + if not entities: + return + + speakers: list[SonosSpeaker] = [] + for entity in entities: + assert isinstance(entity, SonosMediaPlayerEntity) + speakers.append(entity.speaker) + + config_entry = speakers[0].config_entry # All speakers share the same entry + + if service_call.service == SERVICE_SNAPSHOT: + await SonosSpeaker.snapshot_multi( + hass, config_entry, speakers, service_call.data[ATTR_WITH_GROUP] + ) + elif service_call.service == SERVICE_RESTORE: + await SonosSpeaker.restore_multi( + hass, config_entry, speakers, service_call.data[ATTR_WITH_GROUP] + ) + + join_unjoin_schema = cv.make_entity_service_schema( + {vol.Optional(ATTR_WITH_GROUP, default=True): cv.boolean} + ) + + hass.services.async_register( + DOMAIN, SERVICE_SNAPSHOT, async_service_handle, join_unjoin_schema + ) + + hass.services.async_register( + DOMAIN, SERVICE_RESTORE, async_service_handle, join_unjoin_schema + ) + + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_SET_TIMER, + entity_domain=MEDIA_PLAYER_DOMAIN, + schema={ + vol.Required(ATTR_SLEEP_TIME): vol.All( + vol.Coerce(int), vol.Range(min=0, max=86399) + ) + }, + func="set_sleep_timer", + ) + + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_CLEAR_TIMER, + entity_domain=MEDIA_PLAYER_DOMAIN, + schema=None, + func="clear_sleep_timer", + ) + + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_UPDATE_ALARM, + entity_domain=MEDIA_PLAYER_DOMAIN, + schema={ + vol.Required(ATTR_ALARM_ID): cv.positive_int, + vol.Optional(ATTR_TIME): cv.time, + vol.Optional(ATTR_VOLUME): cv.small_float, + vol.Optional(ATTR_ENABLED): cv.boolean, + vol.Optional(ATTR_INCLUDE_LINKED_ZONES): cv.boolean, + }, + func="set_alarm", + ) + + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_PLAY_QUEUE, + entity_domain=MEDIA_PLAYER_DOMAIN, + schema={vol.Optional(ATTR_QUEUE_POSITION): cv.positive_int}, + func="play_queue", + ) + + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_REMOVE_FROM_QUEUE, + entity_domain=MEDIA_PLAYER_DOMAIN, + schema={vol.Optional(ATTR_QUEUE_POSITION): cv.positive_int}, + func="remove_from_queue", + ) + + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_GET_QUEUE, + entity_domain=MEDIA_PLAYER_DOMAIN, + schema=None, + func="get_queue", + supports_response=SupportsResponse.ONLY, + ) diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index 41b18750fd4c1a..d606d179487f36 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -33,16 +33,18 @@ SOURCE_TV, ) from homeassistant.components.sonos.media_player import ( + LONG_SERVICE_TIMEOUT, + VOLUME_INCREMENT, +) +from homeassistant.components.sonos.services import ( ATTR_ALARM_ID, ATTR_ENABLED, ATTR_INCLUDE_LINKED_ZONES, ATTR_VOLUME, - LONG_SERVICE_TIMEOUT, SERVICE_GET_QUEUE, SERVICE_RESTORE, SERVICE_SNAPSHOT, SERVICE_UPDATE_ALARM, - VOLUME_INCREMENT, ) from homeassistant.const import ( ATTR_ENTITY_ID, From 343b17788f1d1b6c46a01b95ce2de32445c10d59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20H=C3=B6rsken?= Date: Thu, 11 Sep 2025 12:57:53 +0200 Subject: [PATCH 05/15] Add support for valance shades / volants to WMS WebControl pro (#150882) --- homeassistant/components/wmspro/cover.py | 35 +++++++++----- tests/components/wmspro/conftest.py | 22 +++++++++ .../fixtures/config_prod_awning_valance.json | 46 +++++++++++++++++++ .../wmspro/fixtures/status_prod_valance.json | 28 +++++++++++ tests/components/wmspro/test_cover.py | 15 ++++++ 5 files changed, 135 insertions(+), 11 deletions(-) create mode 100644 tests/components/wmspro/fixtures/config_prod_awning_valance.json create mode 100644 tests/components/wmspro/fixtures/status_prod_valance.json diff --git a/homeassistant/components/wmspro/cover.py b/homeassistant/components/wmspro/cover.py index e7255d478cb296..6aa1fdcd4376b1 100644 --- a/homeassistant/components/wmspro/cover.py +++ b/homeassistant/components/wmspro/cover.py @@ -6,10 +6,11 @@ from typing import Any from wmspro.const import ( - WMS_WebControl_pro_API_actionDescription, + WMS_WebControl_pro_API_actionDescription as ACTION_DESC, WMS_WebControl_pro_API_actionType, WMS_WebControl_pro_API_responseType, ) +from wmspro.destination import Destination from homeassistant.components.cover import ATTR_POSITION, CoverDeviceClass, CoverEntity from homeassistant.core import HomeAssistant @@ -32,11 +33,11 @@ async def async_setup_entry( entities: list[WebControlProGenericEntity] = [] for dest in hub.dests.values(): - if dest.hasAction(WMS_WebControl_pro_API_actionDescription.AwningDrive): + if dest.hasAction(ACTION_DESC.AwningDrive): entities.append(WebControlProAwning(config_entry.entry_id, dest)) - elif dest.hasAction( - WMS_WebControl_pro_API_actionDescription.RollerShutterBlindDrive - ): + if dest.hasAction(ACTION_DESC.ValanceDrive): + entities.append(WebControlProValance(config_entry.entry_id, dest)) + if dest.hasAction(ACTION_DESC.RollerShutterBlindDrive): entities.append(WebControlProRollerShutter(config_entry.entry_id, dest)) async_add_entities(entities) @@ -45,7 +46,7 @@ async def async_setup_entry( class WebControlProCover(WebControlProGenericEntity, CoverEntity): """Base representation of a WMS based cover.""" - _drive_action_desc: WMS_WebControl_pro_API_actionDescription + _drive_action_desc: ACTION_DESC _attr_name = None @property @@ -79,7 +80,7 @@ async def async_close_cover(self, **kwargs: Any) -> None: async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the device if in motion.""" action = self._dest.action( - WMS_WebControl_pro_API_actionDescription.ManualCommand, + ACTION_DESC.ManualCommand, WMS_WebControl_pro_API_actionType.Stop, ) await action(responseType=WMS_WebControl_pro_API_responseType.Detailed) @@ -89,13 +90,25 @@ class WebControlProAwning(WebControlProCover): """Representation of a WMS based awning.""" _attr_device_class = CoverDeviceClass.AWNING - _drive_action_desc = WMS_WebControl_pro_API_actionDescription.AwningDrive + _drive_action_desc = ACTION_DESC.AwningDrive + + +class WebControlProValance(WebControlProCover): + """Representation of a WMS based valance.""" + + _attr_translation_key = "valance" + _attr_device_class = CoverDeviceClass.SHADE + _drive_action_desc = ACTION_DESC.ValanceDrive + + def __init__(self, config_entry_id: str, dest: Destination) -> None: + """Initialize the entity with destination channel.""" + super().__init__(config_entry_id, dest) + if self._attr_unique_id: + self._attr_unique_id += "-valance" class WebControlProRollerShutter(WebControlProCover): """Representation of a WMS based roller shutter or blind.""" _attr_device_class = CoverDeviceClass.SHUTTER - _drive_action_desc = ( - WMS_WebControl_pro_API_actionDescription.RollerShutterBlindDrive - ) + _drive_action_desc = ACTION_DESC.RollerShutterBlindDrive diff --git a/tests/components/wmspro/conftest.py b/tests/components/wmspro/conftest.py index dc648dafcc2b5f..97326773dc09f9 100644 --- a/tests/components/wmspro/conftest.py +++ b/tests/components/wmspro/conftest.py @@ -70,6 +70,18 @@ def mock_hub_configuration_prod_awning_dimmer() -> Generator[AsyncMock]: yield mock_hub_configuration +@pytest.fixture +def mock_hub_configuration_prod_awning_valance() -> Generator[AsyncMock]: + """Override WebControlPro._getConfiguration.""" + with patch( + "wmspro.webcontrol.WebControlPro._getConfiguration", + return_value=load_json_object_fixture( + "config_prod_awning_valance.json", DOMAIN + ), + ) as mock_hub_configuration: + yield mock_hub_configuration + + @pytest.fixture def mock_hub_configuration_prod_roller_shutter() -> Generator[AsyncMock]: """Override WebControlPro._getConfiguration.""" @@ -114,6 +126,16 @@ def mock_hub_status_prod_roller_shutter() -> Generator[AsyncMock]: yield mock_hub_status +@pytest.fixture +def mock_hub_status_prod_valance() -> Generator[AsyncMock]: + """Override WebControlPro._getStatus.""" + with patch( + "wmspro.webcontrol.WebControlPro._getStatus", + return_value=load_json_object_fixture("status_prod_valance.json", DOMAIN), + ) as mock_hub_status: + yield mock_hub_status + + @pytest.fixture def mock_dest_refresh() -> Generator[AsyncMock]: """Override Destination.refresh.""" diff --git a/tests/components/wmspro/fixtures/config_prod_awning_valance.json b/tests/components/wmspro/fixtures/config_prod_awning_valance.json new file mode 100644 index 00000000000000..3196d29335426b --- /dev/null +++ b/tests/components/wmspro/fixtures/config_prod_awning_valance.json @@ -0,0 +1,46 @@ +{ + "command": "getConfiguration", + "protocolVersion": "1.0.0", + "destinations": [ + { + "id": 58717, + "animationType": 1, + "names": ["Markise", "", "", ""], + "actions": [ + { + "id": 0, + "actionType": 0, + "actionDescription": 0, + "minValue": 0, + "maxValue": 100 + }, + { + "id": 2, + "actionType": 0, + "actionDescription": 1, + "minValue": 0, + "maxValue": 100 + }, + { + "id": 16, + "actionType": 6, + "actionDescription": 12 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + } + ] + } + ], + "rooms": [ + { + "id": 62571, + "name": "Raum 0", + "destinations": [58717], + "scenes": [] + } + ], + "scenes": [] +} diff --git a/tests/components/wmspro/fixtures/status_prod_valance.json b/tests/components/wmspro/fixtures/status_prod_valance.json new file mode 100644 index 00000000000000..38fd4054689e39 --- /dev/null +++ b/tests/components/wmspro/fixtures/status_prod_valance.json @@ -0,0 +1,28 @@ +{ + "command": "getStatus", + "protocolVersion": "1.0.0", + "details": [ + { + "destinationId": 58717, + "data": { + "drivingCause": 0, + "heartbeatError": false, + "blocking": false, + "productData": [ + { + "actionId": 0, + "value": { + "percentage": 100 + } + }, + { + "actionId": 2, + "value": { + "percentage": 100 + } + } + ] + } + } + ] +} diff --git a/tests/components/wmspro/test_cover.py b/tests/components/wmspro/test_cover.py index f28d7f849efbb5..72b251223dd744 100644 --- a/tests/components/wmspro/test_cover.py +++ b/tests/components/wmspro/test_cover.py @@ -81,6 +81,11 @@ async def test_cover_update( "mock_hub_status_prod_awning", "cover.markise", ), + ( + "mock_hub_configuration_prod_awning_valance", + "mock_hub_status_prod_valance", + "cover.markise_2", + ), ( "mock_hub_configuration_prod_roller_shutter", "mock_hub_status_prod_roller_shutter", @@ -159,6 +164,11 @@ async def test_cover_open_and_close( "mock_hub_status_prod_awning", "cover.markise", ), + ( + "mock_hub_configuration_prod_awning_valance", + "mock_hub_status_prod_valance", + "cover.markise_2", + ), ( "mock_hub_configuration_prod_roller_shutter", "mock_hub_status_prod_roller_shutter", @@ -218,6 +228,11 @@ async def test_cover_open_to_pos( "mock_hub_status_prod_awning", "cover.markise", ), + ( + "mock_hub_configuration_prod_awning_valance", + "mock_hub_status_prod_valance", + "cover.markise_2", + ), ( "mock_hub_configuration_prod_roller_shutter", "mock_hub_status_prod_roller_shutter", From a0cef80cf2b3f81c774786eaba7d86b66bf91210 Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Thu, 11 Sep 2025 18:58:36 +0800 Subject: [PATCH 06/15] Add sensors for switchbot cloud integration (#147663) --- .../components/switchbot_cloud/__init__.py | 23 +- .../switchbot_cloud/binary_sensor.py | 42 ++ .../components/switchbot_cloud/icons.json | 17 + .../components/switchbot_cloud/sensor.py | 17 + .../components/switchbot_cloud/strings.json | 5 + tests/components/switchbot_cloud/__init__.py | 41 ++ .../fixtures/meter_status.json | 9 - .../switchbot_cloud/fixtures/status.json | 48 ++ .../snapshots/test_binary_sensor.ambr | 442 ++++++++++++++++++ .../snapshots/test_sensor.ambr | 355 +++++++++++++- .../switchbot_cloud/test_binary_sensor.py | 47 +- .../components/switchbot_cloud/test_sensor.py | 73 ++- 12 files changed, 1047 insertions(+), 72 deletions(-) delete mode 100644 tests/components/switchbot_cloud/fixtures/meter_status.json create mode 100644 tests/components/switchbot_cloud/fixtures/status.json create mode 100644 tests/components/switchbot_cloud/snapshots/test_binary_sensor.ambr diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index edf30984fe6335..44d1f8f30e5123 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -189,6 +189,27 @@ async def make_device_data( hass, entry, api, device, coordinators_by_id ) devices_data.fans.append((device, coordinator)) + if isinstance(device, Device) and device.device_type in [ + "Motion Sensor", + "Contact Sensor", + ]: + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id, True + ) + devices_data.sensors.append((device, coordinator)) + devices_data.binary_sensors.append((device, coordinator)) + if isinstance(device, Device) and device.device_type in ["Hub 3"]: + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id, True + ) + devices_data.sensors.append((device, coordinator)) + devices_data.binary_sensors.append((device, coordinator)) + if isinstance(device, Device) and device.device_type in ["Water Detector"]: + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id, True + ) + devices_data.binary_sensors.append((device, coordinator)) + devices_data.sensors.append((device, coordinator)) if isinstance(device, Device) and device.device_type in [ "Battery Circulator Fan", @@ -377,7 +398,7 @@ async def _internal_handle_webhook( ): _LOGGER.debug("Received invalid data from switchbot webhook %s", repr(data)) return - + _LOGGER.debug("Received data from switchbot webhook: %s", repr(data)) deviceMac = data["context"]["deviceMac"] if deviceMac not in coordinators_by_id: diff --git a/homeassistant/components/switchbot_cloud/binary_sensor.py b/homeassistant/components/switchbot_cloud/binary_sensor.py index a1ad6d6887d384..936300621f22d8 100644 --- a/homeassistant/components/switchbot_cloud/binary_sensor.py +++ b/homeassistant/components/switchbot_cloud/binary_sensor.py @@ -1,6 +1,8 @@ """Support for SwitchBot Cloud binary sensors.""" +from collections.abc import Callable from dataclasses import dataclass +from typing import Any from switchbot_api import Device, SwitchBotAPI @@ -26,6 +28,7 @@ class SwitchBotCloudBinarySensorEntityDescription(BinarySensorEntityDescription) # Value or values to consider binary sensor to be "on" on_value: bool | str = True + value_fn: Callable[[dict[str, Any]], bool | None] | None = None CALIBRATION_DESCRIPTION = SwitchBotCloudBinarySensorEntityDescription( @@ -43,6 +46,34 @@ class SwitchBotCloudBinarySensorEntityDescription(BinarySensorEntityDescription) on_value="opened", ) +MOVE_DETECTED_DESCRIPTION = SwitchBotCloudBinarySensorEntityDescription( + key="moveDetected", + device_class=BinarySensorDeviceClass.MOTION, + value_fn=( + lambda data: data.get("moveDetected") is True + or data.get("detectionState") == "DETECTED" + ), +) + +IS_LIGHT_DESCRIPTION = SwitchBotCloudBinarySensorEntityDescription( + key="brightness", + device_class=BinarySensorDeviceClass.LIGHT, + on_value="bright", +) + +LEAK_DESCRIPTION = SwitchBotCloudBinarySensorEntityDescription( + key="status", + device_class=BinarySensorDeviceClass.MOISTURE, + value_fn=lambda data: any(data.get(key) for key in ("status", "detectionState")), +) + +OPEN_DESCRIPTION = SwitchBotCloudBinarySensorEntityDescription( + key="openState", + device_class=BinarySensorDeviceClass.OPENING, + value_fn=lambda data: data.get("openState") in ("open", "timeOutNotClose"), +) + + BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { "Smart Lock": ( CALIBRATION_DESCRIPTION, @@ -65,6 +96,14 @@ class SwitchBotCloudBinarySensorEntityDescription(BinarySensorEntityDescription) "Roller Shade": (CALIBRATION_DESCRIPTION,), "Blind Tilt": (CALIBRATION_DESCRIPTION,), "Garage Door Opener": (DOOR_OPEN_DESCRIPTION,), + "Motion Sensor": (MOVE_DETECTED_DESCRIPTION,), + "Contact Sensor": ( + MOVE_DETECTED_DESCRIPTION, + IS_LIGHT_DESCRIPTION, + OPEN_DESCRIPTION, + ), + "Hub 3": (MOVE_DETECTED_DESCRIPTION,), + "Water Detector": (LEAK_DESCRIPTION,), } @@ -108,6 +147,9 @@ def is_on(self) -> bool | None: if not self.coordinator.data: return None + if self.entity_description.value_fn: + return self.entity_description.value_fn(self.coordinator.data) + return ( self.coordinator.data.get(self.entity_description.key) == self.entity_description.on_value diff --git a/homeassistant/components/switchbot_cloud/icons.json b/homeassistant/components/switchbot_cloud/icons.json index 2a13cbe75797db..2a468d40a5d37c 100644 --- a/homeassistant/components/switchbot_cloud/icons.json +++ b/homeassistant/components/switchbot_cloud/icons.json @@ -17,6 +17,23 @@ } } } + }, + "sensor": { + "light_level": { + "default": "mdi:brightness-7", + "state": { + "1": "mdi:brightness-1", + "2": "mdi:brightness-1", + "3": "mdi:brightness-1", + "4": "mdi:brightness-1", + "5": "mdi:brightness-2", + "6": "mdi:brightness-3", + "7": "mdi:brightness-4", + "8": "mdi:brightness-5", + "9": "mdi:brightness-6", + "10": "mdi:brightness-7" + } + } } } } diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index 163b1653686119..d5ff5b0e8e7293 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -32,6 +32,8 @@ SENSOR_TYPE_POWER = "power" SENSOR_TYPE_VOLTAGE = "voltage" SENSOR_TYPE_CURRENT = "electricCurrent" +SENSOR_TYPE_LIGHTLEVEL = "lightLevel" + TEMPERATURE_DESCRIPTION = SensorEntityDescription( key=SENSOR_TYPE_TEMPERATURE, @@ -89,6 +91,13 @@ native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ) +LIGHTLEVEL_DESCRIPTION = SensorEntityDescription( + key="lightLevel", + translation_key="light_level", + state_class=SensorStateClass.MEASUREMENT, +) + + SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { "Bot": (BATTERY_DESCRIPTION,), "Battery Circulator Fan": (BATTERY_DESCRIPTION,), @@ -143,6 +152,14 @@ "Curtain3": (BATTERY_DESCRIPTION,), "Roller Shade": (BATTERY_DESCRIPTION,), "Blind Tilt": (BATTERY_DESCRIPTION,), + "Hub 3": ( + TEMPERATURE_DESCRIPTION, + HUMIDITY_DESCRIPTION, + LIGHTLEVEL_DESCRIPTION, + ), + "Motion Sensor": (BATTERY_DESCRIPTION,), + "Contact Sensor": (BATTERY_DESCRIPTION,), + "Water Detector": (BATTERY_DESCRIPTION,), } diff --git a/homeassistant/components/switchbot_cloud/strings.json b/homeassistant/components/switchbot_cloud/strings.json index adb7de00682f27..7ab6ff06792217 100644 --- a/homeassistant/components/switchbot_cloud/strings.json +++ b/homeassistant/components/switchbot_cloud/strings.json @@ -31,6 +31,11 @@ } } } + }, + "sensor": { + "light_level": { + "name": "Light level" + } } } } diff --git a/tests/components/switchbot_cloud/__init__.py b/tests/components/switchbot_cloud/__init__.py index b0d1c29f4a95d2..397c62d32c15d7 100644 --- a/tests/components/switchbot_cloud/__init__.py +++ b/tests/components/switchbot_cloud/__init__.py @@ -40,3 +40,44 @@ async def configure_integration(hass: HomeAssistant) -> MockConfigEntry: deviceType="Battery Circulator Fan", hubDeviceId="test-hub-id", ) + + +METER_INFO = Device( + version="V1.0", + deviceId="meter-id-1", + deviceName="meter-1", + deviceType="Meter", + hubDeviceId="test-hub-id", +) + +CONTACT_SENSOR_INFO = Device( + version="V1.7", + deviceId="contact-sensor-id", + deviceName="contact-sensor-name", + deviceType="Contact Sensor", + hubDeviceId="test-hub-id", +) + +HUB3_INFO = Device( + version="V1.3-0.8-0.1", + deviceId="hub3-id", + deviceName="Hub-3-name", + deviceType="Hub 3", + hubDeviceId="test-hub-id", +) + +MOTION_SENSOR_INFO = Device( + version="V1.9", + deviceId="motion-sensor-id", + deviceName="motion-sensor-name", + deviceType="Motion Sensor", + hubDeviceId="test-hub-id", +) + +WATER_DETECTOR_INFO = Device( + version="V1.7", + deviceId="water-detector-id", + deviceName="water-detector-name", + deviceType="Water Detector", + hubDeviceId="test-hub-id", +) diff --git a/tests/components/switchbot_cloud/fixtures/meter_status.json b/tests/components/switchbot_cloud/fixtures/meter_status.json deleted file mode 100644 index 8b5bcd0c031ead..00000000000000 --- a/tests/components/switchbot_cloud/fixtures/meter_status.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "version": "V3.3", - "temperature": 21.8, - "battery": 100, - "humidity": 32, - "deviceId": "meter-id-1", - "deviceType": "Meter", - "hubDeviceId": "test-hub-id" -} diff --git a/tests/components/switchbot_cloud/fixtures/status.json b/tests/components/switchbot_cloud/fixtures/status.json new file mode 100644 index 00000000000000..87eae6cc93e90d --- /dev/null +++ b/tests/components/switchbot_cloud/fixtures/status.json @@ -0,0 +1,48 @@ +[ + {}, + { + "version": "V3.3", + "temperature": 21.8, + "battery": 100, + "humidity": 32, + "deviceId": "meter-id-1", + "deviceType": "Meter", + "hubDeviceId": "test-hub-id" + }, + { + "version": "V1.7", + "battery": 60, + "moveDetected": true, + "brightness": "bright", + "openState": "timeOutNotClose", + "deviceId": "contact-sensor-id", + "deviceType": "Contact Sensor", + "hubDeviceId": "test-hub-id" + }, + { + "version": "V1.3-0.8-0.1", + "temperature": 26.5, + "lightLevel": 10, + "humidity": 55, + "moveDetected": false, + "deviceId": "hub3-id", + "deviceType": "Hub 3", + "hubDeviceId": "test-hub-id" + }, + { + "version": "V1.9", + "battery": 20, + "moveDetected": false, + "deviceId": "motion-sensor-id", + "deviceType": "Motion Sensor", + "hubDeviceId": "test-hub-id" + }, + { + "version": "V1.7", + "battery": 90, + "status": 1, + "deviceId": "water-detector-id", + "deviceType": "Water Detector", + "hubDeviceId": "test-hub-id" + } +] diff --git a/tests/components/switchbot_cloud/snapshots/test_binary_sensor.ambr b/tests/components/switchbot_cloud/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000000..0fb71d921959bb --- /dev/null +++ b/tests/components/switchbot_cloud/snapshots/test_binary_sensor.ambr @@ -0,0 +1,442 @@ +# serializer version: 1 +# name: test_binary_sensors[device_info0-0][binary_sensor.contact_sensor_name_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.contact_sensor_name_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Light', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'contact-sensor-id_brightness', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[device_info0-0][binary_sensor.contact_sensor_name_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'light', + 'friendly_name': 'contact-sensor-name Light', + }), + 'context': , + 'entity_id': 'binary_sensor.contact_sensor_name_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensors[device_info0-0][binary_sensor.contact_sensor_name_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.contact_sensor_name_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'contact-sensor-id_moveDetected', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[device_info0-0][binary_sensor.contact_sensor_name_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': 'contact-sensor-name Motion', + }), + 'context': , + 'entity_id': 'binary_sensor.contact_sensor_name_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensors[device_info0-0][binary_sensor.contact_sensor_name_opening-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.contact_sensor_name_opening', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Opening', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'contact-sensor-id_openState', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[device_info0-0][binary_sensor.contact_sensor_name_opening-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'opening', + 'friendly_name': 'contact-sensor-name Opening', + }), + 'context': , + 'entity_id': 'binary_sensor.contact_sensor_name_opening', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensors[device_info1-2][binary_sensor.contact_sensor_name_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.contact_sensor_name_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Light', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'contact-sensor-id_brightness', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[device_info1-2][binary_sensor.contact_sensor_name_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'light', + 'friendly_name': 'contact-sensor-name Light', + }), + 'context': , + 'entity_id': 'binary_sensor.contact_sensor_name_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[device_info1-2][binary_sensor.contact_sensor_name_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.contact_sensor_name_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'contact-sensor-id_moveDetected', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[device_info1-2][binary_sensor.contact_sensor_name_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': 'contact-sensor-name Motion', + }), + 'context': , + 'entity_id': 'binary_sensor.contact_sensor_name_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[device_info1-2][binary_sensor.contact_sensor_name_opening-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.contact_sensor_name_opening', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Opening', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'contact-sensor-id_openState', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[device_info1-2][binary_sensor.contact_sensor_name_opening-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'opening', + 'friendly_name': 'contact-sensor-name Opening', + }), + 'context': , + 'entity_id': 'binary_sensor.contact_sensor_name_opening', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[device_info2-3][binary_sensor.hub_3_name_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.hub_3_name_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'hub3-id_moveDetected', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[device_info2-3][binary_sensor.hub_3_name_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': 'Hub-3-name Motion', + }), + 'context': , + 'entity_id': 'binary_sensor.hub_3_name_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[device_info3-4][binary_sensor.motion_sensor_name_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.motion_sensor_name_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'motion-sensor-id_moveDetected', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[device_info3-4][binary_sensor.motion_sensor_name_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': 'motion-sensor-name Motion', + }), + 'context': , + 'entity_id': 'binary_sensor.motion_sensor_name_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[device_info4-5][binary_sensor.water_detector_name_moisture-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.water_detector_name_moisture', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Moisture', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'water-detector-id_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[device_info4-5][binary_sensor.water_detector_name_moisture-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'water-detector-name Moisture', + }), + 'context': , + 'entity_id': 'binary_sensor.water_detector_name_moisture', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/switchbot_cloud/snapshots/test_sensor.ambr b/tests/components/switchbot_cloud/snapshots/test_sensor.ambr index 83d4fa6b5a39e1..85b2fcc2dcf474 100644 --- a/tests/components/switchbot_cloud/snapshots/test_sensor.ambr +++ b/tests/components/switchbot_cloud/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_meter[sensor.meter_1_battery-entry] +# name: test_meter[device_info0-0][sensor.meter_1_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -36,7 +36,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_meter[sensor.meter_1_battery-state] +# name: test_meter[device_info0-0][sensor.meter_1_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', @@ -49,10 +49,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': 'unknown', }) # --- -# name: test_meter[sensor.meter_1_humidity-entry] +# name: test_meter[device_info0-0][sensor.meter_1_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -89,7 +89,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_meter[sensor.meter_1_humidity-state] +# name: test_meter[device_info0-0][sensor.meter_1_humidity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'humidity', @@ -102,10 +102,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '32', + 'state': 'unknown', }) # --- -# name: test_meter[sensor.meter_1_temperature-entry] +# name: test_meter[device_info0-0][sensor.meter_1_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -145,7 +145,7 @@ 'unit_of_measurement': , }) # --- -# name: test_meter[sensor.meter_1_temperature-state] +# name: test_meter[device_info0-0][sensor.meter_1_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -158,10 +158,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '21.8', + 'state': 'unknown', }) # --- -# name: test_meter_no_coordinator_data[sensor.meter_1_battery-entry] +# name: test_meter[device_info1-1][sensor.meter_1_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -198,7 +198,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_meter_no_coordinator_data[sensor.meter_1_battery-state] +# name: test_meter[device_info1-1][sensor.meter_1_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', @@ -211,10 +211,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '100', }) # --- -# name: test_meter_no_coordinator_data[sensor.meter_1_humidity-entry] +# name: test_meter[device_info1-1][sensor.meter_1_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -251,7 +251,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_meter_no_coordinator_data[sensor.meter_1_humidity-state] +# name: test_meter[device_info1-1][sensor.meter_1_humidity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'humidity', @@ -264,10 +264,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '32', }) # --- -# name: test_meter_no_coordinator_data[sensor.meter_1_temperature-entry] +# name: test_meter[device_info1-1][sensor.meter_1_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -307,7 +307,7 @@ 'unit_of_measurement': , }) # --- -# name: test_meter_no_coordinator_data[sensor.meter_1_temperature-state] +# name: test_meter[device_info1-1][sensor.meter_1_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -320,6 +320,325 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '21.8', + }) +# --- +# name: test_meter[device_info2-2][sensor.contact_sensor_name_battery-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.contact_sensor_name_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'contact-sensor-id_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_meter[device_info2-2][sensor.contact_sensor_name_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'contact-sensor-name Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.contact_sensor_name_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_meter[device_info3-3][sensor.hub_3_name_humidity-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.hub_3_name_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'hub3-id_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_meter[device_info3-3][sensor.hub_3_name_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Hub-3-name Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.hub_3_name_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '55', + }) +# --- +# name: test_meter[device_info3-3][sensor.hub_3_name_light_level-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.hub_3_name_light_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light level', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_level', + 'unique_id': 'hub3-id_lightLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_meter[device_info3-3][sensor.hub_3_name_light_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hub-3-name Light level', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.hub_3_name_light_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_meter[device_info3-3][sensor.hub_3_name_temperature-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.hub_3_name_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'hub3-id_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_meter[device_info3-3][sensor.hub_3_name_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Hub-3-name Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.hub_3_name_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.5', + }) +# --- +# name: test_meter[device_info4-4][sensor.motion_sensor_name_battery-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.motion_sensor_name_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'motion-sensor-id_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_meter[device_info4-4][sensor.motion_sensor_name_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'motion-sensor-name Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.motion_sensor_name_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_meter[device_info5-5][sensor.water_detector_name_battery-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.water_detector_name_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'water-detector-id_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_meter[device_info5-5][sensor.water_detector_name_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'water-detector-name Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.water_detector_name_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', }) # --- diff --git a/tests/components/switchbot_cloud/test_binary_sensor.py b/tests/components/switchbot_cloud/test_binary_sensor.py index 753653af9a8b0f..49df3224cc9720 100644 --- a/tests/components/switchbot_cloud/test_binary_sensor.py +++ b/tests/components/switchbot_cloud/test_binary_sensor.py @@ -2,13 +2,24 @@ from unittest.mock import patch +import pytest from switchbot_api import Device +from syrupy.assertion import SnapshotAssertion +from homeassistant.components.switchbot_cloud.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import configure_integration +from . import ( + CONTACT_SENSOR_INFO, + HUB3_INFO, + MOTION_SENSOR_INFO, + WATER_DETECTOR_INFO, + configure_integration, +) + +from tests.common import async_load_json_array_fixture, snapshot_platform async def test_unsupported_device_type( @@ -37,3 +48,37 @@ async def test_unsupported_device_type( # Assert no binary sensor entities were created for unsupported device type entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id) assert len([e for e in entities if e.domain == "binary_sensor"]) == 0 + + +@pytest.mark.parametrize( + ("device_info", "index"), + [ + (CONTACT_SENSOR_INFO, 0), + (CONTACT_SENSOR_INFO, 2), + (HUB3_INFO, 3), + (MOTION_SENSOR_INFO, 4), + (WATER_DETECTOR_INFO, 5), + ], +) +async def test_binary_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_list_devices, + mock_get_status, + device_info: Device, + index: int, +) -> None: + """Test binary sensors.""" + + mock_list_devices.return_value = [device_info] + + json_data = await async_load_json_array_fixture(hass, "status.json", DOMAIN) + mock_get_status.return_value = json_data[index] + + with patch( + "homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.BINARY_SENSOR] + ): + entry = await configure_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/switchbot_cloud/test_sensor.py b/tests/components/switchbot_cloud/test_sensor.py index 99b6acc7401e0f..07a7521686bd0c 100644 --- a/tests/components/switchbot_cloud/test_sensor.py +++ b/tests/components/switchbot_cloud/test_sensor.py @@ -2,6 +2,7 @@ from unittest.mock import patch +import pytest from switchbot_api import Device from syrupy.assertion import SnapshotAssertion @@ -10,58 +11,44 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import configure_integration - -from tests.common import async_load_json_object_fixture, snapshot_platform - - +from . import ( + CONTACT_SENSOR_INFO, + HUB3_INFO, + METER_INFO, + MOTION_SENSOR_INFO, + WATER_DETECTOR_INFO, + configure_integration, +) + +from tests.common import async_load_json_array_fixture, snapshot_platform + + +@pytest.mark.parametrize( + ("device_info", "index"), + [ + (METER_INFO, 0), + (METER_INFO, 1), + (CONTACT_SENSOR_INFO, 2), + (HUB3_INFO, 3), + (MOTION_SENSOR_INFO, 4), + (WATER_DETECTOR_INFO, 5), + ], +) async def test_meter( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, mock_list_devices, mock_get_status, + device_info: Device, + index: int, ) -> None: - """Test Meter sensors.""" - - mock_list_devices.return_value = [ - Device( - version="V1.0", - deviceId="meter-id-1", - deviceName="meter-1", - deviceType="Meter", - hubDeviceId="test-hub-id", - ), - ] - mock_get_status.return_value = await async_load_json_object_fixture( - hass, "meter_status.json", DOMAIN - ) - - with patch("homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.SENSOR]): - entry = await configure_integration(hass) - - await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) - + """Test all sensors.""" -async def test_meter_no_coordinator_data( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, - mock_list_devices, - mock_get_status, -) -> None: - """Test meter sensors are unknown without coordinator data.""" - mock_list_devices.return_value = [ - Device( - version="V1.0", - deviceId="meter-id-1", - deviceName="meter-1", - deviceType="Meter", - hubDeviceId="test-hub-id", - ), - ] + mock_list_devices.return_value = [device_info] - mock_get_status.return_value = None + json_data = await async_load_json_array_fixture(hass, "status.json", DOMAIN) + mock_get_status.return_value = json_data[index] with patch("homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.SENSOR]): entry = await configure_integration(hass) From 7389f23d9a2f2178b43452dc2b89bd556de2876c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Thu, 11 Sep 2025 13:18:55 +0200 Subject: [PATCH 07/15] Bump miele quality scale to platinum (#149736) --- homeassistant/components/miele/manifest.json | 2 +- .../components/miele/quality_scale.yaml | 20 ++++++------------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/miele/manifest.json b/homeassistant/components/miele/manifest.json index 63ace343dc89b5..b5948c4cd18e82 100644 --- a/homeassistant/components/miele/manifest.json +++ b/homeassistant/components/miele/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/miele", "iot_class": "cloud_push", "loggers": ["pymiele"], - "quality_scale": "bronze", + "quality_scale": "platinum", "requirements": ["pymiele==0.5.4"], "single_config_entry": true, "zeroconf": ["_mieleathome._tcp.local."] diff --git a/homeassistant/components/miele/quality_scale.yaml b/homeassistant/components/miele/quality_scale.yaml index 94ce68278efe13..d66f46dc770733 100644 --- a/homeassistant/components/miele/quality_scale.yaml +++ b/homeassistant/components/miele/quality_scale.yaml @@ -1,19 +1,13 @@ rules: # Bronze - action-setup: - status: exempt - comment: | - No custom actions are defined. + action-setup: done appropriate-polling: done brands: done common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: - status: exempt - comment: | - No custom actions are defined. + docs-actions: done docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done @@ -32,9 +26,7 @@ rules: Handled by a setting in manifest.json as there is no account information in API # Silver - action-exceptions: - status: done - comment: No custom actions are defined + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: status: exempt @@ -50,7 +42,7 @@ rules: comment: Handled by DataUpdateCoordinator parallel-updates: done reauthentication-flow: done - test-coverage: todo + test-coverage: done # Gold devices: done @@ -61,11 +53,11 @@ rules: Discovery is just used to initiate setup of the integration. No data from devices is collected. discovery: done docs-data-update: done - docs-examples: todo + docs-examples: done docs-known-limitations: done docs-supported-devices: done docs-supported-functions: done - docs-troubleshooting: todo + docs-troubleshooting: done docs-use-cases: done dynamic-devices: done entity-category: done From 2ed92c720f99ad4a5092d858b26ae16bafbc84de Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 11 Sep 2025 14:23:27 +0200 Subject: [PATCH 08/15] Add entities for Shelly presence component (#151816) --- .../components/shelly/binary_sensor.py | 19 ++++++++ homeassistant/components/shelly/icons.json | 3 ++ homeassistant/components/shelly/sensor.py | 19 ++++++++ homeassistant/components/shelly/strings.json | 3 ++ tests/components/shelly/test_binary_sensor.py | 43 ++++++++++++++++++- tests/components/shelly/test_sensor.py | 41 ++++++++++++++++++ 6 files changed, 127 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 7bec1ab1686ca0..e1261411da3e63 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -73,6 +73,17 @@ def is_on(self) -> bool: return bool(self.attribute_value) +class RpcPresenceBinarySensor(RpcBinarySensor): + """Represent a RPC binary sensor entity for presence component.""" + + @property + def available(self) -> bool: + """Available.""" + available = super().available + + return available and self.coordinator.device.config[self.key]["enable"] + + class RpcBluTrvBinarySensor(RpcBinarySensor): """Represent a RPC BluTrv binary sensor.""" @@ -283,6 +294,14 @@ def __init__( name="Mute", entity_category=EntityCategory.DIAGNOSTIC, ), + "presence_num_objects": RpcBinarySensorDescription( + key="presence", + sub_key="num_objects", + value=lambda status, _: bool(status), + name="Occupancy", + device_class=BinarySensorDeviceClass.OCCUPANCY, + entity_class=RpcPresenceBinarySensor, + ), } diff --git a/homeassistant/components/shelly/icons.json b/homeassistant/components/shelly/icons.json index 6760400a1f73b3..832cf2b4c8f250 100644 --- a/homeassistant/components/shelly/icons.json +++ b/homeassistant/components/shelly/icons.json @@ -20,6 +20,9 @@ } }, "sensor": { + "detected_objects": { + "default": "mdi:account-group" + }, "gas_concentration": { "default": "mdi:gauge" }, diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 2a1478f13077bd..a357ebdbd44e6b 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -124,6 +124,17 @@ def native_value(self) -> StateType: return self.option_map[attribute_value] +class RpcPresenceSensor(RpcSensor): + """Represent a RPC presence sensor.""" + + @property + def available(self) -> bool: + """Available.""" + available = super().available + + return available and self.coordinator.device.config[self.key]["enable"] + + class RpcEmeterPhaseSensor(RpcSensor): """Represent a RPC energy meter phase sensor.""" @@ -1428,6 +1439,14 @@ def __init__( device_class=SensorDeviceClass.ENUM, options=["dark", "twilight", "bright"], ), + "presence_num_objects": RpcSensorDescription( + key="presence", + sub_key="num_objects", + translation_key="detected_objects", + name="Detected objects", + state_class=SensorStateClass.MEASUREMENT, + entity_class=RpcPresenceSensor, + ), } diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 0c1d7051275021..e8b789c5582aef 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -141,6 +141,9 @@ } }, "sensor": { + "detected_objects": { + "unit_of_measurement": "objects" + }, "gas_detected": { "state": { "none": "None", diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index 061c22cf51231e..70e324b6c995ce 100644 --- a/tests/components/shelly/test_binary_sensor.py +++ b/tests/components/shelly/test_binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.shelly.const import UPDATE_PERIOD_MULTIPLIER -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry @@ -527,3 +527,44 @@ async def test_rpc_flood_entities( entry = entity_registry.async_get(entity_id) assert entry == snapshot(name=f"{entity_id}-entry") + + +async def test_rpc_presence_component( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + entity_registry: EntityRegistry, +) -> None: + """Test RPC binary sensor entity for presence component.""" + config = deepcopy(mock_rpc_device.config) + config["presence"] = {"enable": True} + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["presence"] = {"num_objects": 2} + monkeypatch.setattr(mock_rpc_device, "status", status) + + mock_config_entry = await init_integration(hass, 4) + + entity_id = f"{BINARY_SENSOR_DOMAIN}.test_name_occupancy" + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON + + assert (entry := entity_registry.async_get(entity_id)) + assert entry.unique_id == "123456789ABC-presence-presence_num_objects" + + mutate_rpc_device_status(monkeypatch, mock_rpc_device, "presence", "num_objects", 0) + mock_rpc_device.mock_update() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF + + config = deepcopy(mock_rpc_device.config) + config["presence"] = {"enable": False} + monkeypatch.setattr(mock_rpc_device, "config", config) + await hass.config_entries.async_reload(mock_config_entry.entry_id) + mock_rpc_device.mock_update() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index f2d8684985453f..6ab342b2cf81ab 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -1630,3 +1630,44 @@ async def test_block_friendly_name_sleeping_sensor( assert (state := hass.states.get(entity.entity_id)) assert state.attributes[ATTR_FRIENDLY_NAME] == "Test name Temperature" + + +async def test_rpc_presence_component( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + entity_registry: EntityRegistry, +) -> None: + """Test RPC sensor entity for presence component.""" + config = deepcopy(mock_rpc_device.config) + config["presence"] = {"enable": True} + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["presence"] = {"num_objects": 2} + monkeypatch.setattr(mock_rpc_device, "status", status) + + mock_config_entry = await init_integration(hass, 4) + + entity_id = f"{SENSOR_DOMAIN}.test_name_detected_objects" + + assert (state := hass.states.get(entity_id)) + assert state.state == "2" + + assert (entry := entity_registry.async_get(entity_id)) + assert entry.unique_id == "123456789ABC-presence-presence_num_objects" + + mutate_rpc_device_status(monkeypatch, mock_rpc_device, "presence", "num_objects", 0) + mock_rpc_device.mock_update() + + assert (state := hass.states.get(entity_id)) + assert state.state == "0" + + config = deepcopy(mock_rpc_device.config) + config["presence"] = {"enable": False} + monkeypatch.setattr(mock_rpc_device, "config", config) + await hass.config_entries.async_reload(mock_config_entry.entry_id) + mock_rpc_device.mock_update() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE From 88e6b0c8d92195fe1008f49e5ea8453c8f52a785 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 11 Sep 2025 08:35:10 -0400 Subject: [PATCH 09/15] Make LocalSource reusable (#151886) --- .../components/media_source/__init__.py | 12 +- .../components/media_source/local_source.py | 242 +++++++++++------- .../media_source/test_local_source.py | 2 +- 3 files changed, 156 insertions(+), 100 deletions(-) diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index efd7c6670d257c..67507769720a98 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -85,7 +85,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: frontend.async_register_built_in_panel( hass, "media-browser", "media_browser", "hass:play-box-multiple" ) - local_source.async_setup(hass) + + # Local sources support + await _process_media_source_platform(hass, DOMAIN, local_source) + hass.http.register_view(local_source.UploadMediaView) + websocket_api.async_register_command(hass, local_source.websocket_remove_media) + await async_process_integration_platforms( hass, DOMAIN, _process_media_source_platform ) @@ -98,7 +103,10 @@ async def _process_media_source_platform( platform: MediaSourceProtocol, ) -> None: """Process a media source platform.""" - hass.data[MEDIA_SOURCE_DATA][domain] = await platform.async_get_media_source(hass) + source = await platform.async_get_media_source(hass) + hass.data[MEDIA_SOURCE_DATA][domain] = source + if isinstance(source, local_source.LocalSource): + hass.http.register_view(local_source.LocalMediaView(hass, source)) @callback diff --git a/homeassistant/components/media_source/local_source.py b/homeassistant/components/media_source/local_source.py index fa30dc9baf3bfe..5a279753507978 100644 --- a/homeassistant/components/media_source/local_source.py +++ b/homeassistant/components/media_source/local_source.py @@ -2,11 +2,12 @@ from __future__ import annotations +import io import logging import mimetypes from pathlib import Path import shutil -from typing import Any, cast +from typing import Any, Protocol, cast from aiohttp import web from aiohttp.web_request import FileField @@ -16,6 +17,7 @@ from homeassistant.components.http import require_admin from homeassistant.components.media_player import BrowseError, MediaClass from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path from .const import DOMAIN, MEDIA_CLASS_MAP, MEDIA_MIME_TYPES, MEDIA_SOURCE_DATA @@ -26,30 +28,49 @@ LOGGER = logging.getLogger(__name__) -@callback -def async_setup(hass: HomeAssistant) -> None: +class PathNotSupportedError(HomeAssistantError): + """Error to indicate a path is not supported.""" + + +class InvalidFileNameError(HomeAssistantError): + """Error to indicate an invalid file name.""" + + +class UploadedFile(Protocol): + """Protocol describing properties of an uploaded file.""" + + filename: str + file: io.IOBase + content_type: str + + +async def async_get_media_source(hass: HomeAssistant) -> LocalSource: """Set up local media source.""" - source = LocalSource(hass) - hass.data[MEDIA_SOURCE_DATA][DOMAIN] = source - hass.http.register_view(LocalMediaView(hass, source)) - hass.http.register_view(UploadMediaView(hass, source)) - websocket_api.async_register_command(hass, websocket_remove_media) + return LocalSource(hass, DOMAIN, "My media", hass.config.media_dirs, "/media") class LocalSource(MediaSource): """Provide local directories as media sources.""" - name: str = "My media" - - def __init__(self, hass: HomeAssistant) -> None: + def __init__( + self, + hass: HomeAssistant, + domain: str, + name: str, + media_dirs: dict[str, str], + url_prefix: str, + ) -> None: """Initialize local source.""" - super().__init__(DOMAIN) + super().__init__(domain) self.hass = hass + self.name = name + self.media_dirs = media_dirs + self.url_prefix = url_prefix @callback def async_full_path(self, source_dir_id: str, location: str) -> Path: """Return full path.""" - base_path = self.hass.config.media_dirs[source_dir_id] + base_path = self.media_dirs[source_dir_id] full_path = Path(base_path, location) full_path.relative_to(base_path) return full_path @@ -57,11 +78,11 @@ def async_full_path(self, source_dir_id: str, location: str) -> Path: @callback def async_parse_identifier(self, item: MediaSourceItem) -> tuple[str, str]: """Parse identifier.""" - if item.domain != DOMAIN: + if item.domain != self.domain: raise Unresolvable("Unknown domain.") source_dir_id, _, location = item.identifier.partition("/") - if source_dir_id not in self.hass.config.media_dirs: + if source_dir_id not in self.media_dirs: raise Unresolvable("Unknown source directory.") try: @@ -74,13 +95,71 @@ def async_parse_identifier(self, item: MediaSourceItem) -> tuple[str, str]: return source_dir_id, location + async def async_delete_media(self, item: MediaSourceItem) -> None: + """Delete media.""" + source_dir_id, location = self.async_parse_identifier(item) + item_path = self.async_full_path(source_dir_id, location) + + def _do_delete() -> None: + if not item_path.exists(): + raise FileNotFoundError("Path does not exist") + + if not item_path.is_file(): + raise PathNotSupportedError("Path is not a file") + + item_path.unlink() + + await self.hass.async_add_executor_job(_do_delete) + + async def async_upload_media( + self, target_folder: MediaSourceItem, uploaded_file: UploadedFile + ) -> str: + """Upload media. + + Return value is the media source ID of the uploaded file. + """ + source_dir_id, location = self.async_parse_identifier(target_folder) + + if not uploaded_file.content_type.startswith(("image/", "video/", "audio/")): + LOGGER.error("Content type not allowed") + raise vol.Invalid("Only images and video are allowed") + + try: + raise_if_invalid_filename(uploaded_file.filename) + except ValueError as err: + raise InvalidFileNameError from err + + target_dir = self.async_full_path(source_dir_id, location) + + def _do_move() -> None: + """Move file to target.""" + if not target_dir.is_dir(): + raise PathNotSupportedError("Target is not an existing directory") + + target_path = target_dir / uploaded_file.filename + + try: + target_path.relative_to(target_dir) + raise_if_invalid_path(str(target_path)) + except ValueError as err: + raise PathNotSupportedError("Invalid path") from err + + with target_path.open("wb") as target_fp: + shutil.copyfileobj(uploaded_file.file, target_fp) + + await self.hass.async_add_executor_job( + _do_move, + ) + + return f"{target_folder.media_source_id}/{uploaded_file.filename}" + async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media to a url.""" source_dir_id, location = self.async_parse_identifier(item) path = self.async_full_path(source_dir_id, location) mime_type, _ = mimetypes.guess_type(str(path)) assert isinstance(mime_type, str) - return PlayMedia(f"/media/{item.identifier}", mime_type, path=path) + return PlayMedia(f"{self.url_prefix}/{item.identifier}", mime_type, path=path) async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource: """Return media.""" @@ -103,8 +182,8 @@ def _browse_media( """Browse media.""" # If only one media dir is configured, use that as the local media root - if source_dir_id is None and len(self.hass.config.media_dirs) == 1: - source_dir_id = list(self.hass.config.media_dirs)[0] + if source_dir_id is None and len(self.media_dirs) == 1: + source_dir_id = list(self.media_dirs)[0] # Multiple folder, root is requested if source_dir_id is None: @@ -112,7 +191,7 @@ def _browse_media( raise BrowseError("Folder not found.") base = BrowseMediaSource( - domain=DOMAIN, + domain=self.domain, identifier="", media_class=MediaClass.DIRECTORY, media_content_type=None, @@ -124,12 +203,12 @@ def _browse_media( base.children = [ self._browse_media(source_dir_id, "") - for source_dir_id in self.hass.config.media_dirs + for source_dir_id in self.media_dirs ] return base - full_path = Path(self.hass.config.media_dirs[source_dir_id], location) + full_path = Path(self.media_dirs[source_dir_id], location) if not full_path.exists(): if location == "": @@ -170,8 +249,8 @@ def _build_item_response( ) media = BrowseMediaSource( - domain=DOMAIN, - identifier=f"{source_dir_id}/{path.relative_to(self.hass.config.media_dirs[source_dir_id])}", + domain=self.domain, + identifier=f"{source_dir_id}/{path.relative_to(self.media_dirs[source_dir_id])}", media_class=media_class, media_content_type=mime_type or "", title=title, @@ -202,13 +281,14 @@ class LocalMediaView(http.HomeAssistantView): Returns media files in config/media. """ - url = "/media/{source_dir_id}/{location:.*}" name = "media" def __init__(self, hass: HomeAssistant, source: LocalSource) -> None: """Initialize the media view.""" self.hass = hass self.source = source + self.name = source.url_prefix.strip("/").replace("/", ":") + self.url = f"{source.url_prefix}/{{source_dir_id}}/{{location:.*}}" async def _validate_media_path(self, source_dir_id: str, location: str) -> Path: """Validate media path and return it if valid.""" @@ -217,7 +297,7 @@ async def _validate_media_path(self, source_dir_id: str, location: str) -> Path: except ValueError as err: raise web.HTTPBadRequest from err - if source_dir_id not in self.hass.config.media_dirs: + if source_dir_id not in self.source.media_dirs: raise web.HTTPNotFound media_path = self.source.async_full_path(source_dir_id, location) @@ -258,21 +338,18 @@ class UploadMediaView(http.HomeAssistantView): url = "/api/media_source/local_source/upload" name = "api:media_source:local_source:upload" - - def __init__(self, hass: HomeAssistant, source: LocalSource) -> None: - """Initialize the media view.""" - self.hass = hass - self.source = source - self.schema = vol.Schema( - { - "media_content_id": str, - "file": FileField, - } - ) + schema = vol.Schema( + { + "media_content_id": str, + "file": FileField, + } + ) @require_admin async def post(self, request: web.Request) -> web.Response: """Handle upload.""" + hass = request.app[http.KEY_HASS] + # Increase max payload request._client_max_size = MAX_UPLOAD_SIZE # noqa: SLF001 @@ -283,55 +360,35 @@ async def post(self, request: web.Request) -> web.Response: raise web.HTTPBadRequest from err try: - item = MediaSourceItem.from_uri(self.hass, data["media_content_id"], None) + target_folder = MediaSourceItem.from_uri( + hass, data["media_content_id"], None + ) except ValueError as err: LOGGER.error("Received invalid upload data: %s", err) raise web.HTTPBadRequest from err + if target_folder.domain != DOMAIN: + raise web.HTTPBadRequest + + source = cast(LocalSource, hass.data[MEDIA_SOURCE_DATA][target_folder.domain]) try: - source_dir_id, location = self.source.async_parse_identifier(item) + uploaded_media_source_id = await source.async_upload_media( + target_folder, data["file"] + ) except Unresolvable as err: - LOGGER.error("Invalid local source ID") + LOGGER.error("Invalid local source ID: %s", data["media_content_id"]) raise web.HTTPBadRequest from err - - uploaded_file: FileField = data["file"] - - if not uploaded_file.content_type.startswith(("image/", "video/", "audio/")): - LOGGER.error("Content type not allowed") - raise vol.Invalid("Only images and video are allowed") - - try: - raise_if_invalid_filename(uploaded_file.filename) - except ValueError as err: - LOGGER.error("Invalid filename") + except InvalidFileNameError as err: + LOGGER.error("Invalid filename uploaded: %s", data["file"].filename) raise web.HTTPBadRequest from err - - try: - await self.hass.async_add_executor_job( - self._move_file, - self.source.async_full_path(source_dir_id, location), - uploaded_file, - ) - except ValueError as err: - LOGGER.error("Moving upload failed: %s", err) + except PathNotSupportedError as err: + LOGGER.error("Invalid path for upload: %s", data["media_content_id"]) raise web.HTTPBadRequest from err + except OSError as err: + LOGGER.error("Error uploading file: %s", err) + raise web.HTTPInternalServerError from err - return self.json( - {"media_content_id": f"{data['media_content_id']}/{uploaded_file.filename}"} - ) - - def _move_file(self, target_dir: Path, uploaded_file: FileField) -> None: - """Move file to target.""" - if not target_dir.is_dir(): - raise ValueError("Target is not an existing directory") - - target_path = target_dir / uploaded_file.filename - - target_path.relative_to(target_dir) - raise_if_invalid_path(str(target_path)) - - with target_path.open("wb") as target_fp: - shutil.copyfileobj(uploaded_file.file, target_fp) + return self.json({"media_content_id": uploaded_media_source_id}) @websocket_api.websocket_command( @@ -352,32 +409,23 @@ async def websocket_remove_media( connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err)) return - source = cast(LocalSource, hass.data[MEDIA_SOURCE_DATA][DOMAIN]) - - try: - source_dir_id, location = source.async_parse_identifier(item) - except Unresolvable as err: - connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err)) + if item.domain != DOMAIN: + connection.send_error( + msg["id"], websocket_api.ERR_INVALID_FORMAT, "Invalid media source domain" + ) return - item_path = source.async_full_path(source_dir_id, location) - - def _do_delete() -> tuple[str, str] | None: - if not item_path.exists(): - return websocket_api.ERR_NOT_FOUND, "Path does not exist" - - if not item_path.is_file(): - return websocket_api.ERR_NOT_SUPPORTED, "Path is not a file" - - item_path.unlink() - return None + source = cast(LocalSource, hass.data[MEDIA_SOURCE_DATA][item.domain]) try: - error = await hass.async_add_executor_job(_do_delete) + await source.async_delete_media(item) + except Unresolvable as err: + connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err)) + except FileNotFoundError as err: + connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, str(err)) + except PathNotSupportedError as err: + connection.send_error(msg["id"], websocket_api.ERR_NOT_SUPPORTED, str(err)) except OSError as err: - error = (websocket_api.ERR_UNKNOWN_ERROR, str(err)) - - if error: - connection.send_error(msg["id"], *error) + connection.send_error(msg["id"], websocket_api.ERR_UNKNOWN_ERROR, str(err)) else: connection.send_result(msg["id"]) diff --git a/tests/components/media_source/test_local_source.py b/tests/components/media_source/test_local_source.py index 259407bfb5aae7..d40dd7475a771d 100644 --- a/tests/components/media_source/test_local_source.py +++ b/tests/components/media_source/test_local_source.py @@ -339,7 +339,7 @@ def create_file(): msg = await client.receive_json() - assert not msg["success"] + assert not msg["success"], bad_id assert msg["error"]["code"] == err assert extra_id_file.exists() From 46463ea4f80a800351c68c5293794647cd4990b9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 11 Sep 2025 08:35:50 -0400 Subject: [PATCH 10/15] Rename Google Gen AI to Google Gemini (#151653) --- homeassistant/brands/google.json | 1 - homeassistant/components/google_gemini/__init__.py | 1 - homeassistant/components/google_gemini/manifest.json | 6 ------ .../google_generative_ai_conversation/manifest.json | 2 +- homeassistant/generated/integrations.json | 8 +------- 5 files changed, 2 insertions(+), 16 deletions(-) delete mode 100644 homeassistant/components/google_gemini/__init__.py delete mode 100644 homeassistant/components/google_gemini/manifest.json diff --git a/homeassistant/brands/google.json b/homeassistant/brands/google.json index 2da0e2426f53f1..872cfc0aac5021 100644 --- a/homeassistant/brands/google.json +++ b/homeassistant/brands/google.json @@ -6,7 +6,6 @@ "google_assistant_sdk", "google_cloud", "google_drive", - "google_gemini", "google_generative_ai_conversation", "google_mail", "google_maps", diff --git a/homeassistant/components/google_gemini/__init__.py b/homeassistant/components/google_gemini/__init__.py deleted file mode 100644 index b0ecda85e6b37c..00000000000000 --- a/homeassistant/components/google_gemini/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Virtual integration: Google Gemini.""" diff --git a/homeassistant/components/google_gemini/manifest.json b/homeassistant/components/google_gemini/manifest.json deleted file mode 100644 index 783a6210a386d6..00000000000000 --- a/homeassistant/components/google_gemini/manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "domain": "google_gemini", - "name": "Google Gemini", - "integration_type": "virtual", - "supported_by": "google_generative_ai_conversation" -} diff --git a/homeassistant/components/google_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json index ce089440b97953..0745aeae071c7f 100644 --- a/homeassistant/components/google_generative_ai_conversation/manifest.json +++ b/homeassistant/components/google_generative_ai_conversation/manifest.json @@ -1,6 +1,6 @@ { "domain": "google_generative_ai_conversation", - "name": "Google Generative AI", + "name": "Google Gemini", "after_dependencies": ["assist_pipeline", "intent"], "codeowners": ["@tronikos", "@ivanlh"], "config_flow": true, diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 4320d274c7d0c0..04f71cac6f2697 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2396,17 +2396,11 @@ "iot_class": "cloud_polling", "name": "Google Drive" }, - "google_gemini": { - "integration_type": "virtual", - "config_flow": false, - "supported_by": "google_generative_ai_conversation", - "name": "Google Gemini" - }, "google_generative_ai_conversation": { "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling", - "name": "Google Generative AI" + "name": "Google Gemini" }, "google_mail": { "integration_type": "service", From e8c1d3dc3c29bc69a71d08a167f2ff76f01bf223 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 07:52:57 -0500 Subject: [PATCH 11/15] Add repair issue for Bluetooth adapter passive mode fallback (#152076) --- homeassistant/components/bluetooth/manager.py | 61 +++++++- .../components/bluetooth/strings.json | 8 ++ tests/components/bluetooth/test_manager.py | 133 +++++++++++++++++- 3 files changed, 198 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 0365ec2449cbaf..c43f7dd5fd7ffa 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -8,8 +8,19 @@ import logging from bleak_retry_connector import BleakSlotManager -from bluetooth_adapters import BluetoothAdapters, adapter_human_name, adapter_model -from habluetooth import BaseHaRemoteScanner, BaseHaScanner, BluetoothManager, HaScanner +from bluetooth_adapters import ( + ADAPTER_TYPE, + BluetoothAdapters, + adapter_human_name, + adapter_model, +) +from habluetooth import ( + BaseHaRemoteScanner, + BaseHaScanner, + BluetoothManager, + BluetoothScanningMode, + HaScanner, +) from homeassistant import config_entries from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED @@ -326,7 +337,53 @@ def on_scanner_start(self, scanner: BaseHaScanner) -> None: # Only handle repair issues for local adapters (HaScanner instances) if not isinstance(scanner, HaScanner): return + self.async_check_degraded_mode(scanner) + self.async_check_scanning_mode(scanner) + @hass_callback + def async_check_scanning_mode(self, scanner: HaScanner) -> None: + """Check if the scanner is running in passive mode when active mode is requested.""" + passive_mode_issue_id = f"bluetooth_adapter_passive_mode_{scanner.source}" + + # Check if scanner is NOT in passive mode when active mode was requested + if not ( + scanner.requested_mode is BluetoothScanningMode.ACTIVE + and scanner.current_mode is BluetoothScanningMode.PASSIVE + ): + # Delete passive mode issue if it exists and we're not in passive fallback + ir.async_delete_issue(self.hass, DOMAIN, passive_mode_issue_id) + return + + # Create repair issue for passive mode fallback + adapter_name = adapter_human_name( + scanner.adapter, scanner.mac_address or "00:00:00:00:00:00" + ) + adapter_details = self._bluetooth_adapters.adapters.get(scanner.adapter) + model = adapter_model(adapter_details) if adapter_details else None + + # Determine adapter type for specific instructions + # Default to USB for any other type or unknown + if adapter_details and adapter_details.get(ADAPTER_TYPE) == "uart": + translation_key = "bluetooth_adapter_passive_mode_uart" + else: + translation_key = "bluetooth_adapter_passive_mode_usb" + + ir.async_create_issue( + self.hass, + DOMAIN, + passive_mode_issue_id, + is_fixable=False, # Requires a reboot or unplug + severity=ir.IssueSeverity.WARNING, + translation_key=translation_key, + translation_placeholders={ + "adapter": adapter_name, + "model": model or "Unknown", + }, + ) + + @hass_callback + def async_check_degraded_mode(self, scanner: HaScanner) -> None: + """Check if we are in degraded mode and create/delete repair issues.""" issue_id = f"bluetooth_adapter_missing_permissions_{scanner.source}" # Delete any existing issue if not in degraded mode diff --git a/homeassistant/components/bluetooth/strings.json b/homeassistant/components/bluetooth/strings.json index 904f8636ff251c..5cbc3992f16899 100644 --- a/homeassistant/components/bluetooth/strings.json +++ b/homeassistant/components/bluetooth/strings.json @@ -43,6 +43,14 @@ "bluetooth_adapter_missing_permissions": { "title": "Bluetooth adapter requires additional permissions", "description": "The Bluetooth adapter **{adapter}** ({model}) is operating in degraded mode because your container needs additional permissions to fully access Bluetooth hardware.\n\nPlease follow the instructions in our documentation to add the required permissions:\n[Bluetooth permissions for Docker]({docs_url})\n\nAfter adding these permissions, restart your Home Assistant container for the changes to take effect." + }, + "bluetooth_adapter_passive_mode_usb": { + "title": "Bluetooth USB adapter requires manual power cycle", + "description": "The Bluetooth adapter **{adapter}** ({model}) is stuck in passive scanning mode despite requesting active scanning mode. **Automatic recovery was attempted but failed.** This is likely a kernel, firmware, or operating system issue, and the adapter requires a manual power cycle to recover.\n\nIn passive mode, the adapter can only receive advertisements but cannot request additional data from devices, which will affect device discovery and functionality.\n\n**Manual intervention required:**\n1. **Unplug the USB adapter**\n2. Wait 5 seconds\n3. **Plug it back in**\n4. Wait for Home Assistant to detect the adapter\n\nIf the issue persists after power cycling:\n- Try a different USB port\n- Check for kernel/firmware updates\n- Consider using a different Bluetooth adapter" + }, + "bluetooth_adapter_passive_mode_uart": { + "title": "Bluetooth adapter requires system power cycle", + "description": "The Bluetooth adapter **{adapter}** ({model}) is stuck in passive scanning mode despite requesting active scanning mode. **Automatic recovery was attempted but failed.** This is likely a kernel, firmware, or operating system issue, and the system requires a complete power cycle to recover the adapter.\n\nIn passive mode, the adapter can only receive advertisements but cannot request additional data from devices, which will affect device discovery and functionality.\n\n**Manual intervention required:**\n1. **Shut down the system completely** (not just a reboot)\n2. **Remove power** (unplug or turn off at the switch)\n3. Wait 10 seconds\n4. Restore power and boot the system\n\nIf the issue persists after power cycling:\n- Check for kernel/firmware updates\n- The onboard Bluetooth adapter may have hardware issues" } } } diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 54e83007816e8d..a9aa900e4a366c 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -8,7 +8,7 @@ from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import AdvertisementHistory from freezegun import freeze_time -from habluetooth import HaScanner +from habluetooth import BluetoothScanningMode, HaScanner # pylint: disable-next=no-name-in-module from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS @@ -21,7 +21,6 @@ MONOTONIC_TIME, BaseHaRemoteScanner, BluetoothChange, - BluetoothScanningMode, BluetoothServiceInfo, BluetoothServiceInfoBleak, HaBluetoothConnector, @@ -1911,3 +1910,133 @@ async def test_no_repair_issue_for_remote_scanner( and "bluetooth_adapter_missing_permissions" in issue.issue_id ] assert len(issues) == 0 + + +@pytest.mark.usefixtures("one_adapter") +async def test_repair_issue_created_for_passive_mode_fallback( + hass: HomeAssistant, +) -> None: + """Test repair issue is created when scanner falls back to passive mode.""" + assert await async_setup_component(hass, bluetooth.DOMAIN, {}) + await hass.async_block_till_done() + + manager = _get_manager() + + scanner = HaScanner( + mode=BluetoothScanningMode.ACTIVE, + adapter="hci0", + address="00:11:22:33:44:55", + ) + scanner.async_setup() + + cancel = manager.async_register_scanner(scanner, connection_slots=1) + + # Set scanner to passive mode when active was requested + scanner.set_requested_mode(BluetoothScanningMode.ACTIVE) + scanner.set_current_mode(BluetoothScanningMode.PASSIVE) + + manager.on_scanner_start(scanner) + + # Check repair issue is created + issue_id = f"bluetooth_adapter_passive_mode_{scanner.source}" + registry = ir.async_get(hass) + issue = registry.async_get_issue(bluetooth.DOMAIN, issue_id) + assert issue is not None + assert issue.severity == ir.IssueSeverity.WARNING + # Should default to USB translation key when adapter type is unknown + assert issue.translation_key == "bluetooth_adapter_passive_mode_usb" + assert not issue.is_fixable + + cancel() + + +async def test_repair_issue_created_for_passive_mode_fallback_uart( + hass: HomeAssistant, +) -> None: + """Test repair issue is created with UART-specific message for UART adapters.""" + with patch( + "bluetooth_adapters.systems.linux.LinuxAdapters.adapters", + { + "hci0": { + "address": "00:11:22:33:44:55", + "sw_version": "homeassistant", + "hw_version": "uart:bcm2711", + "passive_scan": False, + "manufacturer": "Raspberry Pi", + "product": "BCM2711", + "adapter_type": "uart", # UART adapter type + } + }, + ): + assert await async_setup_component(hass, bluetooth.DOMAIN, {}) + await hass.async_block_till_done() + + manager = _get_manager() + + scanner = HaScanner( + mode=BluetoothScanningMode.ACTIVE, + adapter="hci0", + address="00:11:22:33:44:55", + ) + scanner.async_setup() + + cancel = manager.async_register_scanner(scanner, connection_slots=1) + + # Set scanner to passive mode when active was requested + scanner.set_requested_mode(BluetoothScanningMode.ACTIVE) + scanner.set_current_mode(BluetoothScanningMode.PASSIVE) + + manager.on_scanner_start(scanner) + + # Check repair issue is created with UART-specific translation key + issue_id = f"bluetooth_adapter_passive_mode_{scanner.source}" + registry = ir.async_get(hass) + issue = registry.async_get_issue(bluetooth.DOMAIN, issue_id) + assert issue is not None + assert issue.severity == ir.IssueSeverity.WARNING + assert issue.translation_key == "bluetooth_adapter_passive_mode_uart" + assert not issue.is_fixable + + cancel() + + +@pytest.mark.usefixtures("one_adapter") +async def test_repair_issue_deleted_when_passive_mode_resolved( + hass: HomeAssistant, +) -> None: + """Test repair issue is deleted when scanner no longer in passive mode.""" + assert await async_setup_component(hass, bluetooth.DOMAIN, {}) + await hass.async_block_till_done() + + manager = _get_manager() + + scanner = HaScanner( + mode=BluetoothScanningMode.ACTIVE, + adapter="hci0", + address="00:11:22:33:44:55", + ) + scanner.async_setup() + + cancel = manager.async_register_scanner(scanner, connection_slots=1) + + # Initially set scanner to passive mode when active was requested + scanner.set_requested_mode(BluetoothScanningMode.ACTIVE) + scanner.set_current_mode(BluetoothScanningMode.PASSIVE) + + manager.on_scanner_start(scanner) + + # Check repair issue is created + issue_id = f"bluetooth_adapter_passive_mode_{scanner.source}" + registry = ir.async_get(hass) + issue = registry.async_get_issue(bluetooth.DOMAIN, issue_id) + assert issue is not None + + # Now simulate scanner recovering to active mode + scanner.set_current_mode(BluetoothScanningMode.ACTIVE) + manager.on_scanner_start(scanner) + + # Check repair issue is deleted + issue = registry.async_get_issue(bluetooth.DOMAIN, issue_id) + assert issue is None + + cancel() From 9edd5c35e0d90afbc10a3385c842657ba1eff8d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 11 Sep 2025 14:47:59 +0100 Subject: [PATCH 12/15] Fix duplicated IP port usage in Govee Light Local (#152087) --- .../components/govee_light_local/__init__.py | 19 ++++++++++------ .../govee_light_local/config_flow.py | 17 +++++--------- .../govee_light_local/coordinator.py | 5 ++--- .../govee_light_local/test_config_flow.py | 22 ++++++++++++------- 4 files changed, 33 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/govee_light_local/__init__.py b/homeassistant/components/govee_light_local/__init__.py index 803f4b3ead543c..4315f5d5363d8e 100644 --- a/homeassistant/components/govee_light_local/__init__.py +++ b/homeassistant/components/govee_light_local/__init__.py @@ -26,16 +26,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoveeLocalConfigEntry) -> bool: """Set up Govee light local from a config entry.""" - # Get source IPs for all enabled adapters - source_ips = await network.async_get_enabled_source_ips(hass) + source_ips = await async_get_source_ips(hass) _LOGGER.debug("Enabled source IPs: %s", source_ips) coordinator: GoveeLocalApiCoordinator = GoveeLocalApiCoordinator( - hass=hass, - config_entry=entry, - source_ips=[ - source_ip for source_ip in source_ips if isinstance(source_ip, IPv4Address) - ], + hass=hass, config_entry=entry, source_ips=source_ips ) async def await_cleanup(): @@ -76,3 +71,13 @@ async def await_cleanup(): async def async_unload_entry(hass: HomeAssistant, entry: GoveeLocalConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_get_source_ips( + hass: HomeAssistant, +) -> set[str]: + """Get the source ips for Govee local.""" + source_ips = await network.async_get_enabled_source_ips(hass) + return { + str(source_ip) for source_ip in source_ips if isinstance(source_ip, IPv4Address) + } diff --git a/homeassistant/components/govee_light_local/config_flow.py b/homeassistant/components/govee_light_local/config_flow.py index a1f601b288893c..cd1dc00f9e0e16 100644 --- a/homeassistant/components/govee_light_local/config_flow.py +++ b/homeassistant/components/govee_light_local/config_flow.py @@ -4,15 +4,14 @@ import asyncio from contextlib import suppress -from ipaddress import IPv4Address import logging from govee_local_api import GoveeController -from homeassistant.components import network from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_flow +from . import async_get_source_ips from .const import ( CONF_LISTENING_PORT_DEFAULT, CONF_MULTICAST_ADDRESS_DEFAULT, @@ -24,11 +23,11 @@ _LOGGER = logging.getLogger(__name__) -async def _async_discover(hass: HomeAssistant, adapter_ip: IPv4Address) -> bool: +async def _async_discover(hass: HomeAssistant, adapter_ip: str) -> bool: controller: GoveeController = GoveeController( loop=hass.loop, logger=_LOGGER, - listening_address=str(adapter_ip), + listening_address=adapter_ip, broadcast_address=CONF_MULTICAST_ADDRESS_DEFAULT, broadcast_port=CONF_TARGET_PORT_DEFAULT, listening_port=CONF_LISTENING_PORT_DEFAULT, @@ -62,14 +61,8 @@ async def _async_discover(hass: HomeAssistant, adapter_ip: IPv4Address) -> bool: async def _async_has_devices(hass: HomeAssistant) -> bool: """Return if there are devices that can be discovered.""" - # Get source IPs for all enabled adapters - source_ips = await network.async_get_enabled_source_ips(hass) - _LOGGER.debug("Enabled source IPs: %s", source_ips) - - # Run discovery on every IPv4 address and gather results - results = await asyncio.gather( - *[_async_discover(hass, ip) for ip in source_ips if isinstance(ip, IPv4Address)] - ) + source_ips = await async_get_source_ips(hass) + results = await asyncio.gather(*[_async_discover(hass, ip) for ip in source_ips]) return any(results) diff --git a/homeassistant/components/govee_light_local/coordinator.py b/homeassistant/components/govee_light_local/coordinator.py index 31efeb55680d24..1c2aac12f70c6e 100644 --- a/homeassistant/components/govee_light_local/coordinator.py +++ b/homeassistant/components/govee_light_local/coordinator.py @@ -2,7 +2,6 @@ import asyncio from collections.abc import Callable -from ipaddress import IPv4Address import logging from govee_local_api import GoveeController, GoveeDevice @@ -30,7 +29,7 @@ def __init__( self, hass: HomeAssistant, config_entry: GoveeLocalConfigEntry, - source_ips: list[IPv4Address], + source_ips: set[str], ) -> None: """Initialize my coordinator.""" super().__init__( @@ -45,7 +44,7 @@ def __init__( GoveeController( loop=hass.loop, logger=_LOGGER, - listening_address=str(source_ip), + listening_address=source_ip, broadcast_address=CONF_MULTICAST_ADDRESS_DEFAULT, broadcast_port=CONF_TARGET_PORT_DEFAULT, listening_port=CONF_LISTENING_PORT_DEFAULT, diff --git a/tests/components/govee_light_local/test_config_flow.py b/tests/components/govee_light_local/test_config_flow.py index e6e336a70f29a4..32ef2408c013aa 100644 --- a/tests/components/govee_light_local/test_config_flow.py +++ b/tests/components/govee_light_local/test_config_flow.py @@ -1,6 +1,7 @@ """Test Govee light local config flow.""" from errno import EADDRINUSE +from ipaddress import IPv4Address from unittest.mock import AsyncMock, patch from govee_local_api import GoveeDevice @@ -61,17 +62,22 @@ async def test_creating_entry_has_with_devices( mock_govee_api.devices = _get_devices(mock_govee_api) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) + # Mock duplicated IPs to ensure that only one GoveeController is started + with patch( + "homeassistant.components.network.async_get_enabled_source_ips", + return_value=[IPv4Address("192.168.1.2"), IPv4Address("192.168.1.2")], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) - # Confirmation form - assert result["type"] is FlowResultType.FORM + # Confirmation form + assert result["type"] is FlowResultType.FORM - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.CREATE_ENTRY + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.CREATE_ENTRY - await hass.async_block_till_done() + await hass.async_block_till_done() mock_govee_api.start.assert_awaited_once() mock_setup_entry.assert_awaited_once() From 393826635b79226527b4301f644a42f072113090 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 11 Sep 2025 15:48:59 +0200 Subject: [PATCH 13/15] Add next_flow to config flow result (#151998) --- homeassistant/config_entries.py | 17 +++ tests/test_config_entries.py | 178 +++++++++++++++++++++++++++++++- 2 files changed, 194 insertions(+), 1 deletion(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 65d1a576434160..27e1928ef078ef 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -299,6 +299,7 @@ class ConfigFlowResult(FlowResult[ConfigFlowContext, str], total=False): """Typed result dict for config flow.""" # Extra keys, only present if type is CREATE_ENTRY + next_flow: tuple[FlowType, str] # (flow type, flow id) minor_version: int options: Mapping[str, Any] result: ConfigEntry @@ -306,6 +307,14 @@ class ConfigFlowResult(FlowResult[ConfigFlowContext, str], total=False): version: int +class FlowType(StrEnum): + """Flow type.""" + + CONFIG_FLOW = "config_flow" + # Add other flow types here as needed in the future, + # if we want to support them in the `next_flow` parameter. + + def _validate_item(*, disabled_by: ConfigEntryDisabler | Any | None = None) -> None: """Validate config entry item.""" @@ -3138,6 +3147,7 @@ def async_create_entry( # type: ignore[override] data: Mapping[str, Any], description: str | None = None, description_placeholders: Mapping[str, str] | None = None, + next_flow: tuple[FlowType, str] | None = None, options: Mapping[str, Any] | None = None, subentries: Iterable[ConfigSubentryData] | None = None, ) -> ConfigFlowResult: @@ -3158,6 +3168,13 @@ def async_create_entry( # type: ignore[override] ) result["minor_version"] = self.MINOR_VERSION + if next_flow is not None: + flow_type, flow_id = next_flow + if flow_type != FlowType.CONFIG_FLOW: + raise HomeAssistantError("Invalid next_flow type") + # Raises UnknownFlow if the flow does not exist. + self.hass.config_entries.flow.async_get(flow_id) + result["next_flow"] = next_flow result["options"] = options or {} result["subentries"] = subentries or () result["version"] = self.VERSION diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 06b6dfd0cf43aa..4619d49584a898 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -31,7 +31,12 @@ HomeAssistant, callback, ) -from homeassistant.data_entry_flow import BaseServiceInfo, FlowResult, FlowResultType +from homeassistant.data_entry_flow import ( + BaseServiceInfo, + FlowResult, + FlowResultType, + UnknownFlow, +) from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryError, @@ -1838,6 +1843,177 @@ async def _mock_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ] +async def test_create_entry_next_flow( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test next_flow parameter for create entry.""" + + async def mock_async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Mock setup.""" + return True + + async_setup_entry = AsyncMock(return_value=True) + mock_integration( + hass, + MockModule( + "comp", + async_setup=mock_async_setup, + async_setup_entry=async_setup_entry, + ), + ) + mock_platform(hass, "comp.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + async def async_step_import( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Test create entry with next_flow parameter.""" + result = await hass.config_entries.flow.async_init( + "comp", + context={"source": config_entries.SOURCE_USER}, + ) + return self.async_create_entry( + title="import", + data={"flow": "import"}, + next_flow=(config_entries.FlowType.CONFIG_FLOW, result["flow_id"]), + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Test next step.""" + if user_input is None: + return self.async_show_form(step_id="user") + return self.async_create_entry(title="user", data={"flow": "user"}) + + with mock_config_flow("comp", TestFlow): + assert await async_setup_component(hass, "comp", {}) + + result = await hass.config_entries.flow.async_init( + "comp", + context={"source": config_entries.SOURCE_IMPORT}, + ) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + user_flow = flows[0] + assert async_setup_entry.call_count == 1 + + entries = hass.config_entries.async_entries("comp") + assert len(entries) == 1 + entry = entries[0] + assert result == { + "context": {"source": "import"}, + "data": {"flow": "import"}, + "description_placeholders": None, + "description": None, + "flow_id": ANY, + "handler": "comp", + "minor_version": 1, + "next_flow": (config_entries.FlowType.CONFIG_FLOW, user_flow["flow_id"]), + "options": {}, + "result": entry, + "subentries": (), + "title": "import", + "type": FlowResultType.CREATE_ENTRY, + "version": 1, + } + + result = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], {} + ) + await hass.async_block_till_done() + + assert async_setup_entry.call_count == 2 + entries = hass.config_entries.async_entries("comp") + entry = next(entry for entry in entries if entry.data.get("flow") == "user") + assert result == { + "context": {"source": "user"}, + "data": {"flow": "user"}, + "description_placeholders": None, + "description": None, + "flow_id": user_flow["flow_id"], + "handler": "comp", + "minor_version": 1, + "options": {}, + "result": entry, + "subentries": (), + "title": "user", + "type": FlowResultType.CREATE_ENTRY, + "version": 1, + } + + +@pytest.mark.parametrize( + ("invalid_next_flow", "error"), + [ + (("invalid_flow_type", "invalid_flow_id"), HomeAssistantError), + ((config_entries.FlowType.CONFIG_FLOW, "invalid_flow_id"), UnknownFlow), + ], +) +async def test_create_entry_next_flow_invalid( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + invalid_next_flow: tuple[str, str], + error: type[Exception], +) -> None: + """Test next_flow invalid parameter for create entry.""" + + async def mock_async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Mock setup.""" + return True + + async_setup_entry = AsyncMock(return_value=True) + mock_integration( + hass, + MockModule( + "comp", + async_setup=mock_async_setup, + async_setup_entry=async_setup_entry, + ), + ) + mock_platform(hass, "comp.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + async def async_step_import( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Test create entry with next_flow parameter.""" + await hass.config_entries.flow.async_init( + "comp", + context={"source": config_entries.SOURCE_USER}, + ) + return self.async_create_entry( + title="import", + data={"flow": "import"}, + next_flow=invalid_next_flow, # type: ignore[arg-type] + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Test next step.""" + if user_input is None: + return self.async_show_form(step_id="user") + return self.async_create_entry(title="user", data={"flow": "user"}) + + with mock_config_flow("comp", TestFlow): + assert await async_setup_component(hass, "comp", {}) + + with pytest.raises(error): + await hass.config_entries.flow.async_init( + "comp", + context={"source": config_entries.SOURCE_IMPORT}, + ) + + assert async_setup_entry.call_count == 0 + + async def test_create_entry_options( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: From d4d912ef552366ae99e64913a83ad77fb404c353 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 11 Sep 2025 16:40:39 +0200 Subject: [PATCH 14/15] Add missing "to" in `invalid_auth` exception of `portainer` (#152116) --- homeassistant/components/portainer/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json index 798840e806211a..89530efc2129d9 100644 --- a/homeassistant/components/portainer/strings.json +++ b/homeassistant/components/portainer/strings.json @@ -40,7 +40,7 @@ "message": "An error occurred while trying to connect to the Portainer instance: {error}" }, "invalid_auth": { - "message": "An error occurred while trying authenticate: {error}" + "message": "An error occurred while trying to authenticate: {error}" }, "timeout_connect": { "message": "A timeout occurred while trying to connect to the Portainer instance: {error}" From 1bcf3cfbb217cdfff8ea09c4690dcac7920924da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 09:58:59 -0500 Subject: [PATCH 15/15] Fix DoorBird being updated with wrong IP addresses during discovery (#152088) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/doorbird/config_flow.py | 56 ++++++++++++- .../components/doorbird/strings.json | 2 + tests/components/doorbird/test_config_flow.py | 84 ++++++++++++++++++- 3 files changed, 140 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/doorbird/config_flow.py b/homeassistant/components/doorbird/config_flow.py index 6a954f5310ffd4..ac08ad0e1f624a 100644 --- a/homeassistant/components/doorbird/config_flow.py +++ b/homeassistant/components/doorbird/config_flow.py @@ -19,8 +19,10 @@ ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import VolDictType @@ -103,6 +105,43 @@ def __init__(self) -> None: """Initialize the DoorBird config flow.""" self.discovery_schema: vol.Schema | None = None + async def _async_verify_existing_device_for_discovery( + self, + existing_entry: ConfigEntry, + host: str, + macaddress: str, + ) -> None: + """Verify discovered device matches existing entry before updating IP. + + This method performs the following verification steps: + 1. Ensures that the stored credentials work before updating the entry. + 2. Verifies that the device at the discovered IP address has the expected MAC address. + """ + info, errors = await self._async_validate_or_error( + { + **existing_entry.data, + CONF_HOST: host, + } + ) + + if errors: + _LOGGER.debug( + "Cannot validate DoorBird at %s with existing credentials: %s", + host, + errors, + ) + raise AbortFlow("cannot_connect") + + # Verify the MAC address matches what was advertised + if format_mac(info["mac_addr"]) != format_mac(macaddress): + _LOGGER.debug( + "DoorBird at %s reports MAC %s but zeroconf advertised %s, ignoring", + host, + info["mac_addr"], + macaddress, + ) + raise AbortFlow("wrong_device") + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: @@ -172,7 +211,22 @@ async def async_step_zeroconf( await self.async_set_unique_id(macaddress) host = discovery_info.host - self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + + # Check if we have an existing entry for this MAC + existing_entry = self.hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, macaddress + ) + + if existing_entry: + # Check if the host is actually changing + if existing_entry.data.get(CONF_HOST) != host: + await self._async_verify_existing_device_for_discovery( + existing_entry, host, macaddress + ) + + # All checks passed or no change needed, abort + # if already configured with potential IP update + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) self._async_abort_entries_match({CONF_HOST: host}) diff --git a/homeassistant/components/doorbird/strings.json b/homeassistant/components/doorbird/strings.json index 285b544e465b75..341976e8a8f166 100644 --- a/homeassistant/components/doorbird/strings.json +++ b/homeassistant/components/doorbird/strings.json @@ -49,6 +49,8 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "link_local_address": "Link local addresses are not supported", "not_doorbird_device": "This device is not a DoorBird", + "not_ipv4_address": "Only IPv4 addresses are supported", + "wrong_device": "Device MAC address does not match", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "flow_title": "{name} ({host})", diff --git a/tests/components/doorbird/test_config_flow.py b/tests/components/doorbird/test_config_flow.py index 98b2189dfd97e0..493762df5ef7d0 100644 --- a/tests/components/doorbird/test_config_flow.py +++ b/tests/components/doorbird/test_config_flow.py @@ -108,7 +108,9 @@ async def test_form_zeroconf_link_local_ignored(hass: HomeAssistant) -> None: assert result["reason"] == "link_local_address" -async def test_form_zeroconf_ipv4_address(hass: HomeAssistant) -> None: +async def test_form_zeroconf_ipv4_address( + hass: HomeAssistant, doorbird_api: DoorBird +) -> None: """Test we abort and update the ip address from zeroconf with an ipv4 address.""" config_entry = MockConfigEntry( @@ -118,6 +120,13 @@ async def test_form_zeroconf_ipv4_address(hass: HomeAssistant) -> None: options={CONF_EVENTS: ["event1", "event2", "event3"]}, ) config_entry.add_to_hass(hass) + + # Mock the API to return the correct MAC when validating + doorbird_api.info.return_value = { + "PRIMARY_MAC_ADDR": "1CCAE3AAAAAA", + "WIFI_MAC_ADDR": "1CCAE3BBBBBB", + } + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, @@ -136,6 +145,79 @@ async def test_form_zeroconf_ipv4_address(hass: HomeAssistant) -> None: assert config_entry.data[CONF_HOST] == "4.4.4.4" +async def test_form_zeroconf_ipv4_address_wrong_device( + hass: HomeAssistant, doorbird_api: DoorBird +) -> None: + """Test we abort when the device MAC doesn't match during zeroconf update.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="1CCAE3AAAAAA", + data=VALID_CONFIG, + options={CONF_EVENTS: ["event1", "event2", "event3"]}, + ) + config_entry.add_to_hass(hass) + + # Mock the API to return a different MAC (wrong device) + doorbird_api.info.return_value = { + "PRIMARY_MAC_ADDR": "1CCAE3DIFFERENT", # Different MAC! + "WIFI_MAC_ADDR": "1CCAE3BBBBBB", + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("4.4.4.4"), + ip_addresses=[ip_address("4.4.4.4")], + hostname="mock_hostname", + name="Doorstation - abc123._axis-video._tcp.local.", + port=None, + properties={"macaddress": "1CCAE3AAAAAA"}, + type="mock_type", + ), + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_device" + # Host should not be updated since it's the wrong device + assert config_entry.data[CONF_HOST] == "1.2.3.4" + + +async def test_form_zeroconf_ipv4_address_cannot_connect( + hass: HomeAssistant, doorbird_api: DoorBird +) -> None: + """Test we abort when we cannot connect to validate during zeroconf update.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="1CCAE3AAAAAA", + data=VALID_CONFIG, + options={CONF_EVENTS: ["event1", "event2", "event3"]}, + ) + config_entry.add_to_hass(hass) + + # Mock the API to fail connection (e.g., wrong credentials or network error) + doorbird_api.info.side_effect = mock_unauthorized_exception() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("4.4.4.4"), + ip_addresses=[ip_address("4.4.4.4")], + hostname="mock_hostname", + name="Doorstation - abc123._axis-video._tcp.local.", + port=None, + properties={"macaddress": "1CCAE3AAAAAA"}, + type="mock_type", + ), + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + # Host should not be updated since we couldn't validate + assert config_entry.data[CONF_HOST] == "1.2.3.4" + + async def test_form_zeroconf_non_ipv4_ignored(hass: HomeAssistant) -> None: """Test we abort when we get a non ipv4 address via zeroconf."""