diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 4aa9724f515216..984d1e91c8a2cf 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -160,7 +160,7 @@ jobs: # home-assistant/wheels doesn't support sha pinning - name: Build wheels - uses: home-assistant/wheels@2025.07.0 + uses: home-assistant/wheels@2025.09.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -221,7 +221,7 @@ jobs: # home-assistant/wheels doesn't support sha pinning - name: Build wheels - uses: home-assistant/wheels@2025.07.0 + uses: home-assistant/wheels@2025.09.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 diff --git a/.strict-typing b/.strict-typing index a4152b78ca0cac..d483d04f70263a 100644 --- a/.strict-typing +++ b/.strict-typing @@ -443,6 +443,7 @@ homeassistant.components.rituals_perfume_genie.* homeassistant.components.roborock.* homeassistant.components.roku.* homeassistant.components.romy.* +homeassistant.components.route_b_smart_meter.* homeassistant.components.rpi_power.* homeassistant.components.rss_feed_template.* homeassistant.components.russound_rio.* diff --git a/CODEOWNERS b/CODEOWNERS index 59b72f3550b34f..5a130d0278bd20 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -316,6 +316,8 @@ build.json @home-assistant/supervisor /tests/components/crownstone/ @Crownstone @RicArch97 /homeassistant/components/cups/ @fabaff /tests/components/cups/ @fabaff +/homeassistant/components/cync/ @Kinachi249 +/tests/components/cync/ @Kinachi249 /homeassistant/components/daikin/ @fredrike /tests/components/daikin/ @fredrike /homeassistant/components/date/ @home-assistant/core @@ -1332,6 +1334,8 @@ build.json @home-assistant/supervisor /tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous /homeassistant/components/roon/ @pavoni /tests/components/roon/ @pavoni +/homeassistant/components/route_b_smart_meter/ @SeraphicRav +/tests/components/route_b_smart_meter/ @SeraphicRav /homeassistant/components/rpi_power/ @shenxn @swetoast /tests/components/rpi_power/ @shenxn @swetoast /homeassistant/components/rss_feed_template/ @home-assistant/core diff --git a/homeassistant/components/airzone/select.py b/homeassistant/components/airzone/select.py index c00e83f2c5b32e..813ead8b6a8dc0 100644 --- a/homeassistant/components/airzone/select.py +++ b/homeassistant/components/airzone/select.py @@ -6,17 +6,19 @@ from dataclasses import dataclass from typing import Any, Final -from aioairzone.common import GrilleAngle, OperationMode, SleepTimeout +from aioairzone.common import GrilleAngle, OperationMode, QAdapt, SleepTimeout from aioairzone.const import ( API_COLD_ANGLE, API_HEAT_ANGLE, API_MODE, + API_Q_ADAPT, API_SLEEP, AZD_COLD_ANGLE, AZD_HEAT_ANGLE, AZD_MASTER, AZD_MODE, AZD_MODES, + AZD_Q_ADAPT, AZD_SLEEP, AZD_ZONES, ) @@ -65,6 +67,14 @@ class AirzoneSelectDescription(SelectEntityDescription): "90m": SleepTimeout.SLEEP_90, } +Q_ADAPT_DICT: Final[dict[str, int]] = { + "standard": QAdapt.STANDARD, + "power": QAdapt.POWER, + "silence": QAdapt.SILENCE, + "minimum": QAdapt.MINIMUM, + "maximum": QAdapt.MAXIMUM, +} + def main_zone_options( zone_data: dict[str, Any], @@ -83,6 +93,14 @@ def main_zone_options( options_fn=main_zone_options, translation_key="modes", ), + AirzoneSelectDescription( + api_param=API_Q_ADAPT, + entity_category=EntityCategory.CONFIG, + key=AZD_Q_ADAPT, + options=list(Q_ADAPT_DICT), + options_dict=Q_ADAPT_DICT, + translation_key="q_adapt", + ), ) diff --git a/homeassistant/components/airzone/strings.json b/homeassistant/components/airzone/strings.json index c7d9701aa83729..0b783769803bee 100644 --- a/homeassistant/components/airzone/strings.json +++ b/homeassistant/components/airzone/strings.json @@ -63,6 +63,16 @@ "stop": "Stop" } }, + "q_adapt": { + "name": "Q-Adapt", + "state": { + "standard": "Standard", + "power": "Power", + "silence": "Silence", + "minimum": "Minimum", + "maximum": "Maximum" + } + }, "sleep_times": { "name": "Sleep", "state": { diff --git a/homeassistant/components/alexa_devices/binary_sensor.py b/homeassistant/components/alexa_devices/binary_sensor.py index 231f144dd8944a..410ea4555e247f 100644 --- a/homeassistant/components/alexa_devices/binary_sensor.py +++ b/homeassistant/components/alexa_devices/binary_sensor.py @@ -94,12 +94,24 @@ async def async_setup_entry( coordinator = entry.runtime_data - async_add_entities( - AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc) - for sensor_desc in BINARY_SENSORS - for serial_num in coordinator.data - if sensor_desc.is_supported(coordinator.data[serial_num], sensor_desc.key) - ) + known_devices: set[str] = set() + + def _check_device() -> None: + current_devices = set(coordinator.data) + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc) + for sensor_desc in BINARY_SENSORS + for serial_num in new_devices + if sensor_desc.is_supported( + coordinator.data[serial_num], sensor_desc.key + ) + ) + + _check_device() + entry.async_on_unload(coordinator.async_add_listener(_check_device)) class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity): diff --git a/homeassistant/components/alexa_devices/notify.py b/homeassistant/components/alexa_devices/notify.py index 08f2e214f38c5c..d046b580cb7ffe 100644 --- a/homeassistant/components/alexa_devices/notify.py +++ b/homeassistant/components/alexa_devices/notify.py @@ -57,13 +57,23 @@ async def async_setup_entry( coordinator = entry.runtime_data - async_add_entities( - AmazonNotifyEntity(coordinator, serial_num, sensor_desc) - for sensor_desc in NOTIFY - for serial_num in coordinator.data - if sensor_desc.subkey in coordinator.data[serial_num].capabilities - and sensor_desc.is_supported(coordinator.data[serial_num]) - ) + known_devices: set[str] = set() + + def _check_device() -> None: + current_devices = set(coordinator.data) + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + AmazonNotifyEntity(coordinator, serial_num, sensor_desc) + for sensor_desc in NOTIFY + for serial_num in new_devices + if sensor_desc.subkey in coordinator.data[serial_num].capabilities + and sensor_desc.is_supported(coordinator.data[serial_num]) + ) + + _check_device() + entry.async_on_unload(coordinator.async_add_listener(_check_device)) class AmazonNotifyEntity(AmazonEntity, NotifyEntity): diff --git a/homeassistant/components/alexa_devices/quality_scale.yaml b/homeassistant/components/alexa_devices/quality_scale.yaml index da48f366a6c245..0933f17835991e 100644 --- a/homeassistant/components/alexa_devices/quality_scale.yaml +++ b/homeassistant/components/alexa_devices/quality_scale.yaml @@ -53,7 +53,7 @@ rules: docs-supported-functions: done docs-troubleshooting: done docs-use-cases: done - dynamic-devices: todo + dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: done diff --git a/homeassistant/components/alexa_devices/sensor.py b/homeassistant/components/alexa_devices/sensor.py index 738e0ac2de575f..1a863e87c1a77d 100644 --- a/homeassistant/components/alexa_devices/sensor.py +++ b/homeassistant/components/alexa_devices/sensor.py @@ -62,12 +62,22 @@ async def async_setup_entry( coordinator = entry.runtime_data - async_add_entities( - AmazonSensorEntity(coordinator, serial_num, sensor_desc) - for sensor_desc in SENSORS - for serial_num in coordinator.data - if coordinator.data[serial_num].sensors.get(sensor_desc.key) is not None - ) + known_devices: set[str] = set() + + def _check_device() -> None: + current_devices = set(coordinator.data) + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + AmazonSensorEntity(coordinator, serial_num, sensor_desc) + for sensor_desc in SENSORS + for serial_num in new_devices + if coordinator.data[serial_num].sensors.get(sensor_desc.key) is not None + ) + + _check_device() + entry.async_on_unload(coordinator.async_add_listener(_check_device)) class AmazonSensorEntity(AmazonEntity, SensorEntity): diff --git a/homeassistant/components/alexa_devices/switch.py b/homeassistant/components/alexa_devices/switch.py index e53ea40965a812..138013666c6e44 100644 --- a/homeassistant/components/alexa_devices/switch.py +++ b/homeassistant/components/alexa_devices/switch.py @@ -48,12 +48,22 @@ async def async_setup_entry( coordinator = entry.runtime_data - async_add_entities( - AmazonSwitchEntity(coordinator, serial_num, switch_desc) - for switch_desc in SWITCHES - for serial_num in coordinator.data - if switch_desc.subkey in coordinator.data[serial_num].capabilities - ) + known_devices: set[str] = set() + + def _check_device() -> None: + current_devices = set(coordinator.data) + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + AmazonSwitchEntity(coordinator, serial_num, switch_desc) + for switch_desc in SWITCHES + for serial_num in new_devices + if switch_desc.subkey in coordinator.data[serial_num].capabilities + ) + + _check_device() + entry.async_on_unload(coordinator.async_add_listener(_check_device)) class AmazonSwitchEntity(AmazonEntity, SwitchEntity): diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 22e641c414a40e..5795be4e027999 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -39,7 +39,7 @@ from homeassistant.helpers.singleton import singleton from homeassistant.helpers.storage import Store from homeassistant.helpers.system_info import async_get_system_info -from homeassistant.helpers.typing import UNDEFINED, UndefinedType +from homeassistant.helpers.typing import UNDEFINED from homeassistant.loader import ( Integration, IntegrationNotFound, @@ -142,7 +142,6 @@ class EntityAnalyticsModifications: """ remove: bool = False - capabilities: dict[str, Any] | None | UndefinedType = UNDEFINED class AnalyticsPlatformProtocol(Protocol): @@ -677,18 +676,14 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # we should replace it with the original value in the future. # It is also not present, if entity is not in the state machine, # which can happen for disabled entities. - "assumed_state": entity_state.attributes.get(ATTR_ASSUMED_STATE, False) - if entity_state is not None - else None, - "capabilities": entity_config.capabilities - if entity_config.capabilities is not UNDEFINED - else entity_entry.capabilities, + "assumed_state": ( + entity_state.attributes.get(ATTR_ASSUMED_STATE, False) + if entity_state is not None + else None + ), "domain": entity_entry.domain, "entity_category": entity_entry.entity_category, "has_entity_name": entity_entry.has_entity_name, - "modified_by_integration": ["capabilities"] - if entity_config.capabilities is not UNDEFINED - else None, "original_device_class": entity_entry.original_device_class, # LIMITATION: `unit_of_measurement` can be overridden by users; # we should replace it with the original value in the future. diff --git a/homeassistant/components/comelit/binary_sensor.py b/homeassistant/components/comelit/binary_sensor.py index e1be330afae5ed..68390642c877dd 100644 --- a/homeassistant/components/comelit/binary_sensor.py +++ b/homeassistant/components/comelit/binary_sensor.py @@ -29,10 +29,23 @@ async def async_setup_entry( coordinator = cast(ComelitVedoSystem, config_entry.runtime_data) - async_add_entities( - ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id) - for device in coordinator.data["alarm_zones"].values() - ) + known_devices: set[int] = set() + + def _check_device() -> None: + current_devices = set(coordinator.data["alarm_zones"]) + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + ComelitVedoBinarySensorEntity( + coordinator, device, config_entry.entry_id + ) + for device in coordinator.data["alarm_zones"].values() + if device.index in new_devices + ) + + _check_device() + config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) class ComelitVedoBinarySensorEntity( diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index 691ebaec638ccf..70525ffe7123b1 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -29,10 +29,21 @@ async def async_setup_entry( coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) - async_add_entities( - ComelitCoverEntity(coordinator, device, config_entry.entry_id) - for device in coordinator.data[COVER].values() - ) + known_devices: set[int] = set() + + def _check_device() -> None: + current_devices = set(coordinator.data[COVER]) + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + ComelitCoverEntity(coordinator, device, config_entry.entry_id) + for device in coordinator.data[COVER].values() + if device.index in new_devices + ) + + _check_device() + config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity): diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index c04b88c78197de..8ff626ed9166b1 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -27,10 +27,21 @@ async def async_setup_entry( coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) - async_add_entities( - ComelitLightEntity(coordinator, device, config_entry.entry_id) - for device in coordinator.data[LIGHT].values() - ) + known_devices: set[int] = set() + + def _check_device() -> None: + current_devices = set(coordinator.data[LIGHT]) + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + ComelitLightEntity(coordinator, device, config_entry.entry_id) + for device in coordinator.data[LIGHT].values() + if device.index in new_devices + ) + + _check_device() + config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) class ComelitLightEntity(ComelitBridgeBaseEntity, LightEntity): diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 44101f0fd06c66..4e8fee1bba6322 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["aiocomelit==0.12.3"] } diff --git a/homeassistant/components/comelit/quality_scale.yaml b/homeassistant/components/comelit/quality_scale.yaml index 3d512e713516cd..21c54e00679eea 100644 --- a/homeassistant/components/comelit/quality_scale.yaml +++ b/homeassistant/components/comelit/quality_scale.yaml @@ -57,9 +57,7 @@ rules: docs-supported-functions: done docs-troubleshooting: done docs-use-cases: done - dynamic-devices: - status: todo - comment: missing implementation + dynamic-devices: done entity-category: status: exempt comment: no config or diagnostic entities diff --git a/homeassistant/components/comelit/sensor.py b/homeassistant/components/comelit/sensor.py index a11cac4e1c0ad8..f47a88723687b0 100644 --- a/homeassistant/components/comelit/sensor.py +++ b/homeassistant/components/comelit/sensor.py @@ -4,7 +4,7 @@ from typing import Final, cast -from aiocomelit import ComelitSerialBridgeObject, ComelitVedoZoneObject +from aiocomelit.api import ComelitSerialBridgeObject, ComelitVedoZoneObject from aiocomelit.const import BRIDGE, OTHER, AlarmZoneState from homeassistant.components.sensor import ( @@ -65,15 +65,24 @@ async def async_setup_bridge_entry( coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) - entities: list[ComelitBridgeSensorEntity] = [] - for device in coordinator.data[OTHER].values(): - entities.extend( - ComelitBridgeSensorEntity( - coordinator, device, config_entry.entry_id, sensor_desc + known_devices: set[int] = set() + + def _check_device() -> None: + current_devices = set(coordinator.data[OTHER]) + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + ComelitBridgeSensorEntity( + coordinator, device, config_entry.entry_id, sensor_desc + ) + for sensor_desc in SENSOR_BRIDGE_TYPES + for device in coordinator.data[OTHER].values() + if device.index in new_devices ) - for sensor_desc in SENSOR_BRIDGE_TYPES - ) - async_add_entities(entities) + + _check_device() + config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) async def async_setup_vedo_entry( @@ -85,15 +94,24 @@ async def async_setup_vedo_entry( coordinator = cast(ComelitVedoSystem, config_entry.runtime_data) - entities: list[ComelitVedoSensorEntity] = [] - for device in coordinator.data["alarm_zones"].values(): - entities.extend( - ComelitVedoSensorEntity( - coordinator, device, config_entry.entry_id, sensor_desc + known_devices: set[int] = set() + + def _check_device() -> None: + current_devices = set(coordinator.data["alarm_zones"]) + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + ComelitVedoSensorEntity( + coordinator, device, config_entry.entry_id, sensor_desc + ) + for sensor_desc in SENSOR_VEDO_TYPES + for device in coordinator.data["alarm_zones"].values() + if device.index in new_devices ) - for sensor_desc in SENSOR_VEDO_TYPES - ) - async_add_entities(entities) + + _check_device() + config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity): diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py index 1896071596fe91..076b6091a3dc52 100644 --- a/homeassistant/components/comelit/switch.py +++ b/homeassistant/components/comelit/switch.py @@ -39,6 +39,25 @@ async def async_setup_entry( ) async_add_entities(entities) + known_devices: dict[str, set[int]] = { + dev_type: set() for dev_type in (IRRIGATION, OTHER) + } + + def _check_device() -> None: + for dev_type in (IRRIGATION, OTHER): + current_devices = set(coordinator.data[dev_type]) + new_devices = current_devices - known_devices[dev_type] + if new_devices: + known_devices[dev_type].update(new_devices) + async_add_entities( + ComelitSwitchEntity(coordinator, device, config_entry.entry_id) + for device in coordinator.data[dev_type].values() + if device.index in new_devices + ) + + _check_device() + config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) + class ComelitSwitchEntity(ComelitBridgeBaseEntity, SwitchEntity): """Switch device.""" diff --git a/homeassistant/components/cync/__init__.py b/homeassistant/components/cync/__init__.py new file mode 100644 index 00000000000000..a2fa7ad509a8dd --- /dev/null +++ b/homeassistant/components/cync/__init__.py @@ -0,0 +1,58 @@ +"""The Cync integration.""" + +from __future__ import annotations + +from pycync import Auth, Cync, User +from pycync.exceptions import AuthFailedError, CyncError + +from homeassistant.const import CONF_ACCESS_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + CONF_AUTHORIZE_STRING, + CONF_EXPIRES_AT, + CONF_REFRESH_TOKEN, + CONF_USER_ID, +) +from .coordinator import CyncConfigEntry, CyncCoordinator + +_PLATFORMS: list[Platform] = [Platform.LIGHT] + + +async def async_setup_entry(hass: HomeAssistant, entry: CyncConfigEntry) -> bool: + """Set up Cync from a config entry.""" + user_info = User( + entry.data[CONF_ACCESS_TOKEN], + entry.data[CONF_REFRESH_TOKEN], + entry.data[CONF_AUTHORIZE_STRING], + entry.data[CONF_USER_ID], + expires_at=entry.data[CONF_EXPIRES_AT], + ) + cync_auth = Auth(async_get_clientsession(hass), user=user_info) + + try: + cync = await Cync.create(cync_auth) + except AuthFailedError as ex: + raise ConfigEntryAuthFailed("User token invalid") from ex + except CyncError as ex: + raise ConfigEntryNotReady("Unable to connect to Cync") from ex + + devices_coordinator = CyncCoordinator(hass, entry, cync) + + cync.set_update_callback(devices_coordinator.on_data_update) + + await devices_coordinator.async_config_entry_first_refresh() + entry.runtime_data = devices_coordinator + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: CyncConfigEntry) -> bool: + """Unload a config entry.""" + cync = entry.runtime_data.cync + await cync.shut_down() + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/cync/config_flow.py b/homeassistant/components/cync/config_flow.py new file mode 100644 index 00000000000000..b10f1c03cc384d --- /dev/null +++ b/homeassistant/components/cync/config_flow.py @@ -0,0 +1,118 @@ +"""Config flow for the Cync integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pycync import Auth +from pycync.exceptions import AuthFailedError, CyncError, TwoFactorRequiredError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + CONF_AUTHORIZE_STRING, + CONF_EXPIRES_AT, + CONF_REFRESH_TOKEN, + CONF_TWO_FACTOR_CODE, + CONF_USER_ID, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + } +) + +STEP_TWO_FACTOR_SCHEMA = vol.Schema({vol.Required(CONF_TWO_FACTOR_CODE): str}) + + +class CyncConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Cync.""" + + VERSION = 1 + + cync_auth: Auth + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Attempt login with user credentials.""" + errors: dict[str, str] = {} + + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + self.cync_auth = Auth( + async_get_clientsession(self.hass), + username=user_input[CONF_EMAIL], + password=user_input[CONF_PASSWORD], + ) + try: + await self.cync_auth.login() + except AuthFailedError: + errors["base"] = "invalid_auth" + except TwoFactorRequiredError: + return await self.async_step_two_factor() + except CyncError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return await self._create_config_entry(self.cync_auth.username) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_two_factor( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Attempt login with the two factor auth code sent to the user.""" + errors: dict[str, str] = {} + + if user_input is None: + return self.async_show_form( + step_id="two_factor", data_schema=STEP_TWO_FACTOR_SCHEMA, errors=errors + ) + try: + await self.cync_auth.login(user_input[CONF_TWO_FACTOR_CODE]) + except AuthFailedError: + errors["base"] = "invalid_auth" + except CyncError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return await self._create_config_entry(self.cync_auth.username) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def _create_config_entry(self, user_email: str) -> ConfigFlowResult: + """Create the Cync config entry using input user data.""" + + cync_user = self.cync_auth.user + await self.async_set_unique_id(str(cync_user.user_id)) + self._abort_if_unique_id_configured() + + config = { + CONF_USER_ID: cync_user.user_id, + CONF_AUTHORIZE_STRING: cync_user.authorize, + CONF_EXPIRES_AT: cync_user.expires_at, + CONF_ACCESS_TOKEN: cync_user.access_token, + CONF_REFRESH_TOKEN: cync_user.refresh_token, + } + return self.async_create_entry(title=user_email, data=config) diff --git a/homeassistant/components/cync/const.py b/homeassistant/components/cync/const.py new file mode 100644 index 00000000000000..410863b624d15a --- /dev/null +++ b/homeassistant/components/cync/const.py @@ -0,0 +1,9 @@ +"""Constants for the Cync integration.""" + +DOMAIN = "cync" + +CONF_TWO_FACTOR_CODE = "two_factor_code" +CONF_USER_ID = "user_id" +CONF_AUTHORIZE_STRING = "authorize_string" +CONF_EXPIRES_AT = "expires_at" +CONF_REFRESH_TOKEN = "refresh_token" diff --git a/homeassistant/components/cync/coordinator.py b/homeassistant/components/cync/coordinator.py new file mode 100644 index 00000000000000..84bfa6d0fee702 --- /dev/null +++ b/homeassistant/components/cync/coordinator.py @@ -0,0 +1,87 @@ +"""Coordinator to handle keeping device states up to date.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +import time + +from pycync import Cync, CyncDevice, User +from pycync.exceptions import AuthFailedError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import CONF_EXPIRES_AT, CONF_REFRESH_TOKEN + +_LOGGER = logging.getLogger(__name__) + +type CyncConfigEntry = ConfigEntry[CyncCoordinator] + + +class CyncCoordinator(DataUpdateCoordinator[dict[int, CyncDevice]]): + """Coordinator to handle updating Cync device states.""" + + config_entry: CyncConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: CyncConfigEntry, cync: Cync + ) -> None: + """Initialize the Cync coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Cync Data Coordinator", + config_entry=config_entry, + update_interval=timedelta(seconds=30), + always_update=True, + ) + self.cync = cync + + async def on_data_update(self, data: dict[int, CyncDevice]) -> None: + """Update registered devices with new data.""" + merged_data = self.data | data if self.data else data + self.async_set_updated_data(merged_data) + + async def _async_setup(self) -> None: + """Set up the coordinator with initial device states.""" + logged_in_user = self.cync.get_logged_in_user() + if logged_in_user.access_token != self.config_entry.data[CONF_ACCESS_TOKEN]: + await self._update_config_cync_credentials(logged_in_user) + + async def _async_update_data(self) -> dict[int, CyncDevice]: + """First, refresh the user's auth token if it is set to expire in less than one hour. + + Then, fetch all current device states. + """ + + logged_in_user = self.cync.get_logged_in_user() + if logged_in_user.expires_at - time.time() < 3600: + await self._async_refresh_cync_credentials() + + self.cync.update_device_states() + current_device_states = self.cync.get_devices() + + return {device.device_id: device for device in current_device_states} + + async def _async_refresh_cync_credentials(self) -> None: + """Attempt to refresh the Cync user's authentication token.""" + + try: + refreshed_user = await self.cync.refresh_credentials() + except AuthFailedError as ex: + raise ConfigEntryAuthFailed("Unable to refresh user token") from ex + else: + await self._update_config_cync_credentials(refreshed_user) + + async def _update_config_cync_credentials(self, user_info: User) -> None: + """Update the config entry with current user info.""" + + new_data = {**self.config_entry.data} + new_data[CONF_ACCESS_TOKEN] = user_info.access_token + new_data[CONF_REFRESH_TOKEN] = user_info.refresh_token + new_data[CONF_EXPIRES_AT] = user_info.expires_at + self.hass.config_entries.async_update_entry(self.config_entry, data=new_data) diff --git a/homeassistant/components/cync/entity.py b/homeassistant/components/cync/entity.py new file mode 100644 index 00000000000000..c2946615e1cec1 --- /dev/null +++ b/homeassistant/components/cync/entity.py @@ -0,0 +1,45 @@ +"""Setup for a generic entity type for the Cync integration.""" + +from pycync.devices import CyncDevice + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import CyncCoordinator + + +class CyncBaseEntity(CoordinatorEntity[CyncCoordinator]): + """Generic base entity for Cync devices.""" + + _attr_has_entity_name = True + + def __init__( + self, + device: CyncDevice, + coordinator: CyncCoordinator, + room_name: str | None = None, + ) -> None: + """Pass coordinator to CoordinatorEntity.""" + super().__init__(coordinator) + + self._cync_device_id = device.device_id + self._attr_unique_id = device.unique_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.unique_id)}, + manufacturer="GE Lighting", + name=device.name, + suggested_area=room_name, + ) + + @property + def available(self) -> bool: + """Determines whether this device is currently available.""" + + return ( + super().available + and self.coordinator.data is not None + and self._cync_device_id in self.coordinator.data + and self.coordinator.data[self._cync_device_id].is_online + ) diff --git a/homeassistant/components/cync/light.py b/homeassistant/components/cync/light.py new file mode 100644 index 00000000000000..8604beab41781e --- /dev/null +++ b/homeassistant/components/cync/light.py @@ -0,0 +1,180 @@ +"""Support for Cync light entities.""" + +from typing import Any + +from pycync import CyncLight +from pycync.devices.capabilities import CyncCapability + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_RGB_COLOR, + ColorMode, + LightEntity, + filter_supported_color_modes, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.color import value_to_brightness +from homeassistant.util.scaling import scale_ranged_value_to_int_range + +from .coordinator import CyncConfigEntry, CyncCoordinator +from .entity import CyncBaseEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CyncConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Cync lights from a config entry.""" + + coordinator = entry.runtime_data + cync = coordinator.cync + + entities_to_add = [] + + for home in cync.get_homes(): + for room in home.rooms: + room_lights = [ + CyncLightEntity(device, coordinator, room.name) + for device in room.devices + if isinstance(device, CyncLight) + ] + entities_to_add.extend(room_lights) + + group_lights = [ + CyncLightEntity(device, coordinator, room.name) + for group in room.groups + for device in group.devices + if isinstance(device, CyncLight) + ] + entities_to_add.extend(group_lights) + + async_add_entities(entities_to_add) + + +class CyncLightEntity(CyncBaseEntity, LightEntity): + """Representation of a Cync light.""" + + _attr_color_mode = ColorMode.ONOFF + _attr_min_color_temp_kelvin = 2000 + _attr_max_color_temp_kelvin = 7000 + _attr_translation_key = "light" + _attr_name = None + + BRIGHTNESS_SCALE = (0, 100) + + def __init__( + self, + device: CyncLight, + coordinator: CyncCoordinator, + room_name: str | None = None, + ) -> None: + """Set up base attributes.""" + super().__init__(device, coordinator, room_name) + + supported_color_modes = {ColorMode.ONOFF} + if device.supports_capability(CyncCapability.CCT_COLOR): + supported_color_modes.add(ColorMode.COLOR_TEMP) + if device.supports_capability(CyncCapability.DIMMING): + supported_color_modes.add(ColorMode.BRIGHTNESS) + if device.supports_capability(CyncCapability.RGB_COLOR): + supported_color_modes.add(ColorMode.RGB) + self._attr_supported_color_modes = filter_supported_color_modes( + supported_color_modes + ) + + @property + def is_on(self) -> bool | None: + """Return True if the light is on.""" + return self._device.is_on + + @property + def brightness(self) -> int: + """Provide the light's current brightness.""" + return value_to_brightness(self.BRIGHTNESS_SCALE, self._device.brightness) + + @property + def color_temp_kelvin(self) -> int: + """Return color temperature in kelvin.""" + return scale_ranged_value_to_int_range( + (1, 100), + (self.min_color_temp_kelvin, self.max_color_temp_kelvin), + self._device.color_temp, + ) + + @property + def rgb_color(self) -> tuple[int, int, int]: + """Provide the light's current color in RGB format.""" + return self._device.rgb + + @property + def color_mode(self) -> str | None: + """Return the active color mode.""" + + if ( + self._device.supports_capability(CyncCapability.CCT_COLOR) + and self._device.color_mode > 0 + and self._device.color_mode <= 100 + ): + return ColorMode.COLOR_TEMP + if ( + self._device.supports_capability(CyncCapability.RGB_COLOR) + and self._device.color_mode == 254 + ): + return ColorMode.RGB + if self._device.supports_capability(CyncCapability.DIMMING): + return ColorMode.BRIGHTNESS + + return ColorMode.ONOFF + + async def async_turn_on(self, **kwargs: Any) -> None: + """Process an action on the light.""" + if not kwargs: + await self._device.turn_on() + + elif kwargs.get(ATTR_COLOR_TEMP_KELVIN) is not None: + color_temp = kwargs.get(ATTR_COLOR_TEMP_KELVIN) + converted_color_temp = self._normalize_color_temp(color_temp) + + await self._device.set_color_temp(converted_color_temp) + elif kwargs.get(ATTR_RGB_COLOR) is not None: + rgb = kwargs.get(ATTR_RGB_COLOR) + + await self._device.set_rgb(rgb) + elif kwargs.get(ATTR_BRIGHTNESS) is not None: + brightness = kwargs.get(ATTR_BRIGHTNESS) + converted_brightness = self._normalize_brightness(brightness) + + await self._device.set_brightness(converted_brightness) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" + await self._device.turn_off() + + def _normalize_brightness(self, brightness: float | None) -> int | None: + """Return calculated brightness value scaled between 0-100.""" + if brightness is not None: + return int((brightness / 255) * 100) + + return None + + def _normalize_color_temp(self, color_temp_kelvin: float | None) -> int | None: + """Return calculated color temp value scaled between 1-100.""" + if color_temp_kelvin is not None: + kelvin_range = self.max_color_temp_kelvin - self.min_color_temp_kelvin + scaled_kelvin = int( + ((color_temp_kelvin - self.min_color_temp_kelvin) / kelvin_range) * 100 + ) + if scaled_kelvin == 0: + scaled_kelvin += 1 + + return scaled_kelvin + return None + + @property + def _device(self) -> CyncLight: + """Fetch the reference to the backing Cync light for this device.""" + + return self.coordinator.data[self._cync_device_id] diff --git a/homeassistant/components/cync/manifest.json b/homeassistant/components/cync/manifest.json new file mode 100644 index 00000000000000..d02b6ed1d9b1bd --- /dev/null +++ b/homeassistant/components/cync/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "cync", + "name": "Cync", + "codeowners": ["@Kinachi249"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/cync", + "integration_type": "hub", + "iot_class": "cloud_push", + "quality_scale": "bronze", + "requirements": ["pycync==0.4.0"] +} diff --git a/homeassistant/components/cync/quality_scale.yaml b/homeassistant/components/cync/quality_scale.yaml new file mode 100644 index 00000000000000..7e106cdd49e616 --- /dev/null +++ b/homeassistant/components/cync/quality_scale.yaml @@ -0,0 +1,69 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not provide additional actions. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: todo + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/cync/strings.json b/homeassistant/components/cync/strings.json new file mode 100644 index 00000000000000..0515c053cfcaa5 --- /dev/null +++ b/homeassistant/components/cync/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "Your Cync account's email address", + "password": "Your Cync account's password" + } + }, + "two_factor": { + "data": { + "two_factor_code": "Two-factor code" + }, + "data_description": { + "two_factor_code": "The two-factor code sent to your Cync account's email" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + } +} diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 4835ead2049443..39ff0bc184c876 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==41.9.0", + "aioesphomeapi==41.9.3", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.3.0" ], diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 44dff45029936e..11e703cd73e42e 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250903.5"] + "requirements": ["home-assistant-frontend==20250924.0"] } diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index 98a2fb2f881a02..895c7e72618482 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -123,8 +123,12 @@ async def async_step_pick_firmware( ) -> ConfigFlowResult: """Pick Thread or Zigbee firmware.""" # Determine if ZHA or Thread are already configured to present migrate options - zha_entries = self.hass.config_entries.async_entries(ZHA_DOMAIN) - otbr_entries = self.hass.config_entries.async_entries(OTBR_DOMAIN) + zha_entries = self.hass.config_entries.async_entries( + ZHA_DOMAIN, include_ignore=False + ) + otbr_entries = self.hass.config_entries.async_entries( + OTBR_DOMAIN, include_ignore=False + ) return self.async_show_menu( step_id="pick_firmware", diff --git a/homeassistant/components/input_select/analytics.py b/homeassistant/components/input_select/analytics.py deleted file mode 100644 index a543b822f47d71..00000000000000 --- a/homeassistant/components/input_select/analytics.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Analytics platform.""" - -from homeassistant.components.analytics import ( - AnalyticsInput, - AnalyticsModifications, - EntityAnalyticsModifications, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er - - -async def async_modify_analytics( - hass: HomeAssistant, analytics_input: AnalyticsInput -) -> AnalyticsModifications: - """Modify the analytics.""" - ent_reg = er.async_get(hass) - - entities: dict[str, EntityAnalyticsModifications] = {} - for entity_id in analytics_input.entity_ids: - entity_entry = ent_reg.entities[entity_id] - if entity_entry.capabilities is not None: - capabilities = dict(entity_entry.capabilities) - capabilities["options"] = len(capabilities["options"]) - entities[entity_id] = EntityAnalyticsModifications( - capabilities=capabilities - ) - - return AnalyticsModifications(entities=entities) diff --git a/homeassistant/components/letpot/__init__.py b/homeassistant/components/letpot/__init__.py index 7bcb04b2b4d09a..7e168792887703 100644 --- a/homeassistant/components/letpot/__init__.py +++ b/homeassistant/components/letpot/__init__.py @@ -25,6 +25,7 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, + Platform.NUMBER, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/letpot/icons.json b/homeassistant/components/letpot/icons.json index 1f5e79b04dd17e..aac6326d077988 100644 --- a/homeassistant/components/letpot/icons.json +++ b/homeassistant/components/letpot/icons.json @@ -20,6 +20,14 @@ } } }, + "number": { + "light_brightness": { + "default": "mdi:brightness-5" + }, + "plant_days": { + "default": "mdi:calendar-blank" + } + }, "select": { "display_temperature_unit": { "default": "mdi:thermometer-lines" diff --git a/homeassistant/components/letpot/number.py b/homeassistant/components/letpot/number.py new file mode 100644 index 00000000000000..a5b9c3df68c123 --- /dev/null +++ b/homeassistant/components/letpot/number.py @@ -0,0 +1,136 @@ +"""Support for LetPot number entities.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from letpot.deviceclient import LetPotDeviceClient +from letpot.models import DeviceFeature + +from homeassistant.components.number import ( + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.const import PRECISION_WHOLE, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator +from .entity import LetPotEntity, LetPotEntityDescription, exception_handler + +# Each change pushes a 'full' device status with the change. The library will cache +# pending changes to avoid overwriting, but try to avoid a lot of parallelism. +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class LetPotNumberEntityDescription(LetPotEntityDescription, NumberEntityDescription): + """Describes a LetPot number entity.""" + + max_value_fn: Callable[[LetPotDeviceCoordinator], float] + value_fn: Callable[[LetPotDeviceCoordinator], float | None] + set_value_fn: Callable[[LetPotDeviceClient, str, float], Coroutine[Any, Any, None]] + + +NUMBERS: tuple[LetPotNumberEntityDescription, ...] = ( + LetPotNumberEntityDescription( + key="light_brightness_levels", + translation_key="light_brightness", + value_fn=( + lambda coordinator: coordinator.device_client.get_light_brightness_levels( + coordinator.device.serial_number + ).index(coordinator.data.light_brightness) + + 1 + if coordinator.data.light_brightness is not None + else None + ), + set_value_fn=( + lambda device_client, serial, value: device_client.set_light_brightness( + serial, + device_client.get_light_brightness_levels(serial)[int(value) - 1], + ) + ), + supported_fn=( + lambda coordinator: DeviceFeature.LIGHT_BRIGHTNESS_LEVELS + in coordinator.device_client.device_info( + coordinator.device.serial_number + ).features + ), + native_min_value=float(1), + max_value_fn=lambda coordinator: float( + len( + coordinator.device_client.get_light_brightness_levels( + coordinator.device.serial_number + ) + ) + ), + native_step=PRECISION_WHOLE, + mode=NumberMode.SLIDER, + entity_category=EntityCategory.CONFIG, + ), + LetPotNumberEntityDescription( + key="plant_days", + translation_key="plant_days", + value_fn=lambda coordinator: coordinator.data.plant_days, + set_value_fn=( + lambda device_client, serial, value: device_client.set_plant_days( + serial, int(value) + ) + ), + native_min_value=float(0), + max_value_fn=lambda _: float(999), + native_step=PRECISION_WHOLE, + mode=NumberMode.BOX, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LetPotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up LetPot number entities based on a config entry and device status/features.""" + coordinators = entry.runtime_data + async_add_entities( + LetPotNumberEntity(coordinator, description) + for description in NUMBERS + for coordinator in coordinators + if description.supported_fn(coordinator) + ) + + +class LetPotNumberEntity(LetPotEntity, NumberEntity): + """Defines a LetPot number entity.""" + + entity_description: LetPotNumberEntityDescription + + def __init__( + self, + coordinator: LetPotDeviceCoordinator, + description: LetPotNumberEntityDescription, + ) -> None: + """Initialize LetPot number entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}" + + @property + def native_max_value(self) -> float: + """Return the maximum available value.""" + return self.entity_description.max_value_fn(self.coordinator) + + @property + def native_value(self) -> float | None: + """Return the number value.""" + return self.entity_description.value_fn(self.coordinator) + + @exception_handler + async def async_set_native_value(self, value: float) -> None: + """Change the number value.""" + return await self.entity_description.set_value_fn( + self.coordinator.device_client, + self.coordinator.device.serial_number, + value, + ) diff --git a/homeassistant/components/letpot/strings.json b/homeassistant/components/letpot/strings.json index 6ebd79edf5d777..4c46e1ddbb1621 100644 --- a/homeassistant/components/letpot/strings.json +++ b/homeassistant/components/letpot/strings.json @@ -49,6 +49,15 @@ "name": "Refill error" } }, + "number": { + "light_brightness": { + "name": "Light brightness" + }, + "plant_days": { + "name": "Plants age", + "unit_of_measurement": "days" + } + }, "select": { "display_temperature_unit": { "name": "Temperature unit on display", @@ -58,7 +67,7 @@ } }, "light_brightness": { - "name": "Light brightness", + "name": "[%key:component::letpot::entity::number::light_brightness::name%]", "state": { "low": "[%key:common::state::low%]", "high": "[%key:common::state::high%]" diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py index 36b8f4415b8242..4c27ffb456b68c 100644 --- a/homeassistant/components/modbus/light.py +++ b/homeassistant/components/modbus/light.py @@ -117,7 +117,7 @@ async def async_set_brightness(self, brightness: int) -> None: conv_brightness = self._convert_brightness_to_modbus(brightness) await self._hub.async_pb_call( - device_address=self._device_address, + unit=self._device_address, address=self._brightness_address, value=conv_brightness, use_call=CALL_TYPE_WRITE_REGISTER, @@ -133,7 +133,7 @@ async def async_set_color_temp(self, color_temp_kelvin: int) -> None: conv_color_temp_kelvin = self._convert_color_temp_to_modbus(color_temp_kelvin) await self._hub.async_pb_call( - device_address=self._device_address, + unit=self._device_address, address=self._color_temp_address, value=conv_color_temp_kelvin, use_call=CALL_TYPE_WRITE_REGISTER, @@ -150,7 +150,7 @@ async def _async_update(self) -> None: if self._brightness_address: brightness_result = await self._hub.async_pb_call( - device_address=self._device_address, + unit=self._device_address, value=1, address=self._brightness_address, use_call=CALL_TYPE_REGISTER_HOLDING, @@ -167,7 +167,7 @@ async def _async_update(self) -> None: if self._color_temp_address: color_result = await self._hub.async_pb_call( - device_address=self._device_address, + unit=self._device_address, value=1, address=self._color_temp_address, use_call=CALL_TYPE_REGISTER_HOLDING, diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 89cdb7d47e4483..467ccd6d8216cb 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -370,17 +370,11 @@ async def async_close(self) -> None: _LOGGER.info(f"modbus {self.name} communication closed") async def low_level_pb_call( - self, - device_address: int | None, - address: int, - value: int | list[int], - use_call: str, + self, slave: int | None, address: int, value: int | list[int], use_call: str ) -> ModbusPDU | None: """Call sync. pymodbus.""" kwargs: dict[str, Any] = ( - {DEVICE_ID: device_address} - if device_address is not None - else {DEVICE_ID: 1} + {DEVICE_ID: slave} if slave is not None else {DEVICE_ID: 1} ) entry = self._pb_request[use_call] @@ -392,26 +386,28 @@ async def low_level_pb_call( try: result: ModbusPDU = await entry.func(address, **kwargs) except ModbusException as exception_error: - error = f"Error: device: {device_address} address: {address} -> {exception_error!s}" + error = f"Error: device: {slave} address: {address} -> {exception_error!s}" self._log_error(error) return None if not result: - error = f"Error: device: {device_address} address: {address} -> pymodbus returned None" + error = ( + f"Error: device: {slave} address: {address} -> pymodbus returned None" + ) self._log_error(error) return None if not hasattr(result, entry.attr): - error = f"Error: device: {device_address} address: {address} -> {result!s}" + error = f"Error: device: {slave} address: {address} -> {result!s}" self._log_error(error) return None if result.isError(): - error = f"Error: device: {device_address} address: {address} -> pymodbus returned isError True" + error = f"Error: device: {slave} address: {address} -> pymodbus returned isError True" self._log_error(error) return None return result async def async_pb_call( self, - device_address: int | None, + unit: int | None, address: int, value: int | list[int], use_call: str, @@ -419,7 +415,7 @@ async def async_pb_call( """Convert async to sync pymodbus call.""" if not self._client: return None - result = await self.low_level_pb_call(device_address, address, value, use_call) + result = await self.low_level_pb_call(unit, address, value, use_call) if self._msg_wait: await asyncio.sleep(self._msg_wait) return result diff --git a/homeassistant/components/mqtt/manifest.json b/homeassistant/components/mqtt/manifest.json index 1cd6ae3e47c05c..754d07c10fe571 100644 --- a/homeassistant/components/mqtt/manifest.json +++ b/homeassistant/components/mqtt/manifest.json @@ -6,6 +6,7 @@ "config_flow": true, "dependencies": ["file_upload", "http"], "documentation": "https://www.home-assistant.io/integrations/mqtt", + "integration_type": "service", "iot_class": "local_push", "quality_scale": "platinum", "requirements": ["paho-mqtt==2.1.0"], diff --git a/homeassistant/components/ntfy/config_flow.py b/homeassistant/components/ntfy/config_flow.py index 0a0ea05fcd6e8f..5f168c977c42f6 100644 --- a/homeassistant/components/ntfy/config_flow.py +++ b/homeassistant/components/ntfy/config_flow.py @@ -473,7 +473,12 @@ async def async_step_reconfigure( return self.async_update_and_abort( entry=entry, subentry=subentry, - data_updates=user_input, + data_updates={ + CONF_PRIORITY: user_input.get(CONF_PRIORITY), + CONF_TAGS: user_input.get(CONF_TAGS), + CONF_TITLE: user_input.get(CONF_TITLE), + CONF_MESSAGE: user_input.get(CONF_MESSAGE), + }, ) return self.async_show_form( diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 7d290dc6f0add7..dcda6b843ad15f 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -243,8 +243,45 @@ async def async_will_remove_from_hass(self) -> None: await super().async_will_remove_from_hass() +class ReolinkHostChimeCoordinatorEntity(ReolinkHostCoordinatorEntity): + """Parent class for Reolink chime entities connected to a Host.""" + + def __init__( + self, + reolink_data: ReolinkData, + chime: Chime, + coordinator: DataUpdateCoordinator[None] | None = None, + ) -> None: + """Initialize ReolinkChimeCoordinatorEntity for a chime.""" + super().__init__(reolink_data, coordinator) + self._channel = chime.channel + self._chime = chime + + self._attr_unique_id = ( + f"{self._host.unique_id}_chime{chime.dev_id}_{self.entity_description.key}" + ) + via_dev_id = self._host.unique_id + self._dev_id = f"{self._host.unique_id}_chime{chime.dev_id}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._dev_id)}, + via_device=(DOMAIN, via_dev_id), + name=chime.name, + model="Reolink Chime", + manufacturer=self._host.api.manufacturer, + sw_version=chime.sw_version, + serial_number=str(chime.dev_id), + configuration_url=self._conf_url, + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self._chime.online + + class ReolinkChimeCoordinatorEntity(ReolinkChannelCoordinatorEntity): - """Parent class for Reolink chime entities connected.""" + """Parent class for Reolink chime entities connected through a camera.""" def __init__( self, @@ -255,21 +292,21 @@ def __init__( """Initialize ReolinkChimeCoordinatorEntity for a chime.""" assert chime.channel is not None super().__init__(reolink_data, chime.channel, coordinator) - self._chime = chime self._attr_unique_id = ( f"{self._host.unique_id}_chime{chime.dev_id}_{self.entity_description.key}" ) - cam_dev_id = self._dev_id + via_dev_id = self._dev_id self._dev_id = f"{self._host.unique_id}_chime{chime.dev_id}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._dev_id)}, - via_device=(DOMAIN, cam_dev_id), + via_device=(DOMAIN, via_dev_id), name=chime.name, model="Reolink Chime", manufacturer=self._host.api.manufacturer, + sw_version=chime.sw_version, serial_number=str(chime.dev_id), configuration_url=self._conf_url, ) diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index e7575c207e9936..aaf503d70f8a8f 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -23,6 +23,7 @@ ReolinkChannelEntityDescription, ReolinkChimeCoordinatorEntity, ReolinkChimeEntityDescription, + ReolinkHostChimeCoordinatorEntity, ReolinkHostCoordinatorEntity, ReolinkHostEntityDescription, ) @@ -855,6 +856,12 @@ async def async_setup_entry( for chime in api.chime_list if chime.channel is not None ) + entities.extend( + ReolinkHostChimeNumberEntity(reolink_data, chime, entity_description) + for entity_description in CHIME_NUMBER_ENTITIES + for chime in api.chime_list + if chime.channel is None + ) async_add_entities(entities) @@ -969,7 +976,36 @@ async def async_set_native_value(self, value: float) -> None: class ReolinkChimeNumberEntity(ReolinkChimeCoordinatorEntity, NumberEntity): - """Base number entity class for Reolink IP cameras.""" + """Base number entity class for Reolink chimes connected through a camera.""" + + entity_description: ReolinkChimeNumberEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + chime: Chime, + entity_description: ReolinkChimeNumberEntityDescription, + ) -> None: + """Initialize Reolink chime number entity.""" + self.entity_description = entity_description + super().__init__(reolink_data, chime) + + self._attr_mode = entity_description.mode + + @property + def native_value(self) -> float | None: + """State of the number entity.""" + return self.entity_description.value(self._chime) + + @raise_translated_error + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + await self.entity_description.method(self._chime, value) + self.async_write_ha_state() + + +class ReolinkHostChimeNumberEntity(ReolinkHostChimeCoordinatorEntity, NumberEntity): + """Base number entity class for Reolink chimes connected to the host.""" entity_description: ReolinkChimeNumberEntityDescription diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 7c951038799294..4ce7866625d77b 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -31,6 +31,7 @@ ReolinkChannelEntityDescription, ReolinkChimeCoordinatorEntity, ReolinkChimeEntityDescription, + ReolinkHostChimeCoordinatorEntity, ReolinkHostCoordinatorEntity, ReolinkHostEntityDescription, ) @@ -73,7 +74,7 @@ class ReolinkChimeSelectEntityDescription( get_options: list[str] method: Callable[[Chime, str], Any] - value: Callable[[Chime], str] + value: Callable[[Chime], str | None] def _get_quick_reply_id(api: Host, ch: int, mess: str) -> int: @@ -332,7 +333,7 @@ def _get_quick_reply_id(api: Host, ch: int, mess: str) -> int: entity_category=EntityCategory.CONFIG, supported=lambda chime: "md" in chime.chime_event_types, get_options=[method.name for method in ChimeToneEnum], - value=lambda chime: ChimeToneEnum(chime.tone("md")).name, + value=lambda chime: chime.tone_name("md"), method=lambda chime, name: chime.set_tone("md", ChimeToneEnum[name].value), ), ReolinkChimeSelectEntityDescription( @@ -342,7 +343,7 @@ def _get_quick_reply_id(api: Host, ch: int, mess: str) -> int: entity_category=EntityCategory.CONFIG, get_options=[method.name for method in ChimeToneEnum], supported=lambda chime: "people" in chime.chime_event_types, - value=lambda chime: ChimeToneEnum(chime.tone("people")).name, + value=lambda chime: chime.tone_name("people"), method=lambda chime, name: chime.set_tone("people", ChimeToneEnum[name].value), ), ReolinkChimeSelectEntityDescription( @@ -352,7 +353,7 @@ def _get_quick_reply_id(api: Host, ch: int, mess: str) -> int: entity_category=EntityCategory.CONFIG, get_options=[method.name for method in ChimeToneEnum], supported=lambda chime: "vehicle" in chime.chime_event_types, - value=lambda chime: ChimeToneEnum(chime.tone("vehicle")).name, + value=lambda chime: chime.tone_name("vehicle"), method=lambda chime, name: chime.set_tone("vehicle", ChimeToneEnum[name].value), ), ReolinkChimeSelectEntityDescription( @@ -362,7 +363,7 @@ def _get_quick_reply_id(api: Host, ch: int, mess: str) -> int: entity_category=EntityCategory.CONFIG, get_options=[method.name for method in ChimeToneEnum], supported=lambda chime: "visitor" in chime.chime_event_types, - value=lambda chime: ChimeToneEnum(chime.tone("visitor")).name, + value=lambda chime: chime.tone_name("visitor"), method=lambda chime, name: chime.set_tone("visitor", ChimeToneEnum[name].value), ), ReolinkChimeSelectEntityDescription( @@ -372,7 +373,7 @@ def _get_quick_reply_id(api: Host, ch: int, mess: str) -> int: entity_category=EntityCategory.CONFIG, get_options=[method.name for method in ChimeToneEnum], supported=lambda chime: "package" in chime.chime_event_types, - value=lambda chime: ChimeToneEnum(chime.tone("package")).name, + value=lambda chime: chime.tone_name("package"), method=lambda chime, name: chime.set_tone("package", ChimeToneEnum[name].value), ), ) @@ -386,9 +387,7 @@ async def async_setup_entry( """Set up a Reolink select entities.""" reolink_data: ReolinkData = config_entry.runtime_data - entities: list[ - ReolinkSelectEntity | ReolinkHostSelectEntity | ReolinkChimeSelectEntity - ] = [ + entities: list[SelectEntity] = [ ReolinkSelectEntity(reolink_data, channel, entity_description) for entity_description in SELECT_ENTITIES for channel in reolink_data.host.api.channels @@ -405,6 +404,12 @@ async def async_setup_entry( for chime in reolink_data.host.api.chime_list if entity_description.supported(chime) and chime.channel is not None ) + entities.extend( + ReolinkHostChimeSelectEntity(reolink_data, chime, entity_description) + for entity_description in CHIME_SELECT_ENTITIES + for chime in reolink_data.host.api.chime_list + if entity_description.supported(chime) and chime.channel is None + ) async_add_entities(entities) @@ -481,7 +486,7 @@ async def async_select_option(self, option: str) -> None: class ReolinkChimeSelectEntity(ReolinkChimeCoordinatorEntity, SelectEntity): - """Base select entity class for Reolink IP cameras.""" + """Base select entity class for Reolink chimes connected through a camera.""" entity_description: ReolinkChimeSelectEntityDescription @@ -494,22 +499,40 @@ def __init__( """Initialize Reolink select entity for a chime.""" self.entity_description = entity_description super().__init__(reolink_data, chime) - self._log_error = True self._attr_options = entity_description.get_options @property def current_option(self) -> str | None: """Return the current option.""" - try: - option = self.entity_description.value(self._chime) - except (ValueError, KeyError): - if self._log_error: - _LOGGER.exception("Reolink '%s' has an unknown value", self.name) - self._log_error = False - return None + return self.entity_description.value(self._chime) - self._log_error = True - return option + @raise_translated_error + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.entity_description.method(self._chime, option) + self.async_write_ha_state() + + +class ReolinkHostChimeSelectEntity(ReolinkHostChimeCoordinatorEntity, SelectEntity): + """Base select entity class for Reolink chimes connected to a host.""" + + entity_description: ReolinkChimeSelectEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + chime: Chime, + entity_description: ReolinkChimeSelectEntityDescription, + ) -> None: + """Initialize Reolink select entity for a chime.""" + self.entity_description = entity_description + super().__init__(reolink_data, chime) + self._attr_options = entity_description.get_options + + @property + def current_option(self) -> str | None: + """Return the current option.""" + return self.entity_description.value(self._chime) @raise_translated_error async def async_select_option(self, option: str) -> None: diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index bf18be7b837fa6..d5f45872661ea3 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -20,6 +20,7 @@ ReolinkChannelEntityDescription, ReolinkChimeCoordinatorEntity, ReolinkChimeEntityDescription, + ReolinkHostChimeCoordinatorEntity, ReolinkHostCoordinatorEntity, ReolinkHostEntityDescription, ) @@ -364,9 +365,7 @@ async def async_setup_entry( """Set up a Reolink switch entities.""" reolink_data: ReolinkData = config_entry.runtime_data - entities: list[ - ReolinkSwitchEntity | ReolinkNVRSwitchEntity | ReolinkChimeSwitchEntity - ] = [ + entities: list[SwitchEntity] = [ ReolinkSwitchEntity(reolink_data, channel, entity_description) for entity_description in SWITCH_ENTITIES for channel in reolink_data.host.api.channels @@ -383,6 +382,12 @@ async def async_setup_entry( for chime in reolink_data.host.api.chime_list if chime.channel is not None ) + entities.extend( + ReolinkHostChimeSwitchEntity(reolink_data, chime, entity_description) + for entity_description in CHIME_SWITCH_ENTITIES + for chime in reolink_data.host.api.chime_list + if chime.channel is None + ) # Can be removed in HA 2025.4.0 depricated_dict = {} @@ -511,3 +516,36 @@ async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self.entity_description.method(self._chime, False) self.async_write_ha_state() + + +class ReolinkHostChimeSwitchEntity(ReolinkHostChimeCoordinatorEntity, SwitchEntity): + """Base switch entity class for a chime.""" + + entity_description: ReolinkChimeSwitchEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + chime: Chime, + entity_description: ReolinkChimeSwitchEntityDescription, + ) -> None: + """Initialize Reolink switch entity.""" + self.entity_description = entity_description + super().__init__(reolink_data, chime) + + @property + def is_on(self) -> bool | None: + """Return true if switch is on.""" + return self.entity_description.value(self._chime) + + @raise_translated_error + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.entity_description.method(self._chime, True) + self.async_write_ha_state() + + @raise_translated_error + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self.entity_description.method(self._chime, False) + self.async_write_ha_state() diff --git a/homeassistant/components/route_b_smart_meter/__init__.py b/homeassistant/components/route_b_smart_meter/__init__.py new file mode 100644 index 00000000000000..5e8a941c73e960 --- /dev/null +++ b/homeassistant/components/route_b_smart_meter/__init__.py @@ -0,0 +1,28 @@ +"""The Smart Meter B Route integration.""" + +import logging + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import BRouteConfigEntry, BRouteUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: BRouteConfigEntry) -> bool: + """Set up Smart Meter B Route from a config entry.""" + + coordinator = BRouteUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: BRouteConfigEntry) -> bool: + """Unload a config entry.""" + await hass.async_add_executor_job(entry.runtime_data.api.close) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/route_b_smart_meter/config_flow.py b/homeassistant/components/route_b_smart_meter/config_flow.py new file mode 100644 index 00000000000000..1cbeeab4c4e607 --- /dev/null +++ b/homeassistant/components/route_b_smart_meter/config_flow.py @@ -0,0 +1,116 @@ +"""Config flow for Smart Meter B Route integration.""" + +import logging +from typing import Any + +from momonga import Momonga, MomongaSkJoinFailure, MomongaSkScanFailure +from serial.tools.list_ports import comports +from serial.tools.list_ports_common import ListPortInfo +import voluptuous as vol + +from homeassistant.components.usb import get_serial_by_id, human_readable_device_name +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_DEVICE, CONF_ID, CONF_PASSWORD +from homeassistant.core import callback +from homeassistant.helpers.service_info.usb import UsbServiceInfo + +from .const import DOMAIN, ENTRY_TITLE + +_LOGGER = logging.getLogger(__name__) + + +def _validate_input(device: str, id: str, password: str) -> None: + """Validate the user input allows us to connect.""" + with Momonga(dev=device, rbid=id, pwd=password): + pass + + +def _human_readable_device_name(port: UsbServiceInfo | ListPortInfo) -> str: + return human_readable_device_name( + port.device, + port.serial_number, + port.manufacturer, + port.description, + str(port.vid) if port.vid else None, + str(port.pid) if port.pid else None, + ) + + +class BRouteConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Smart Meter B Route.""" + + VERSION = 1 + + device: UsbServiceInfo | None = None + + @callback + def _get_discovered_device_id_and_name( + self, device_options: dict[str, ListPortInfo] + ) -> tuple[str | None, str | None]: + discovered_device_id = ( + get_serial_by_id(self.device.device) if self.device else None + ) + discovered_device = ( + device_options.get(discovered_device_id) if discovered_device_id else None + ) + discovered_device_name = ( + _human_readable_device_name(discovered_device) + if discovered_device + else None + ) + return discovered_device_id, discovered_device_name + + async def _get_usb_devices(self) -> dict[str, ListPortInfo]: + """Return a list of available USB devices.""" + devices = await self.hass.async_add_executor_job(comports) + return {get_serial_by_id(port.device): port for port in devices} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + device_options = await self._get_usb_devices() + if user_input is not None: + try: + await self.hass.async_add_executor_job( + _validate_input, + user_input[CONF_DEVICE], + user_input[CONF_ID], + user_input[CONF_PASSWORD], + ) + except MomongaSkScanFailure: + errors["base"] = "cannot_connect" + except MomongaSkJoinFailure: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id( + user_input[CONF_ID], raise_on_progress=False + ) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=ENTRY_TITLE, data=user_input) + + discovered_device_id, discovered_device_name = ( + self._get_discovered_device_id_and_name(device_options) + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_DEVICE, default=discovered_device_id): vol.In( + {discovered_device_id: discovered_device_name} + if discovered_device_id and discovered_device_name + else { + name: _human_readable_device_name(device) + for name, device in device_options.items() + } + ), + vol.Required(CONF_ID): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/route_b_smart_meter/const.py b/homeassistant/components/route_b_smart_meter/const.py new file mode 100644 index 00000000000000..ecd3fc48bfcd32 --- /dev/null +++ b/homeassistant/components/route_b_smart_meter/const.py @@ -0,0 +1,12 @@ +"""Constants for the Smart Meter B Route integration.""" + +from datetime import timedelta + +DOMAIN = "route_b_smart_meter" +ENTRY_TITLE = "Route B Smart Meter" +DEFAULT_SCAN_INTERVAL = timedelta(seconds=300) + +ATTR_API_INSTANTANEOUS_POWER = "instantaneous_power" +ATTR_API_TOTAL_CONSUMPTION = "total_consumption" +ATTR_API_INSTANTANEOUS_CURRENT_T_PHASE = "instantaneous_current_t_phase" +ATTR_API_INSTANTANEOUS_CURRENT_R_PHASE = "instantaneous_current_r_phase" diff --git a/homeassistant/components/route_b_smart_meter/coordinator.py b/homeassistant/components/route_b_smart_meter/coordinator.py new file mode 100644 index 00000000000000..7cfa2810b5b0f1 --- /dev/null +++ b/homeassistant/components/route_b_smart_meter/coordinator.py @@ -0,0 +1,75 @@ +"""DataUpdateCoordinator for the Smart Meter B-route integration.""" + +from dataclasses import dataclass +import logging + +from momonga import Momonga, MomongaError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE, CONF_ID, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class BRouteData: + """Class for data of the B Route.""" + + instantaneous_current_r_phase: float + instantaneous_current_t_phase: float + instantaneous_power: float + total_consumption: float + + +type BRouteConfigEntry = ConfigEntry[BRouteUpdateCoordinator] + + +class BRouteUpdateCoordinator(DataUpdateCoordinator[BRouteData]): + """The B Route update coordinator.""" + + def __init__( + self, + hass: HomeAssistant, + entry: BRouteConfigEntry, + ) -> None: + """Initialize.""" + + self.device = entry.data[CONF_DEVICE] + self.bid = entry.data[CONF_ID] + password = entry.data[CONF_PASSWORD] + + self.api = Momonga(dev=self.device, rbid=self.bid, pwd=password) + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + config_entry=entry, + update_interval=DEFAULT_SCAN_INTERVAL, + ) + + async def _async_setup(self) -> None: + await self.hass.async_add_executor_job( + self.api.open, + ) + + def _get_data(self) -> BRouteData: + """Get the data from API.""" + current = self.api.get_instantaneous_current() + return BRouteData( + instantaneous_current_r_phase=current["r phase current"], + instantaneous_current_t_phase=current["t phase current"], + instantaneous_power=self.api.get_instantaneous_power(), + total_consumption=self.api.get_measured_cumulative_energy(), + ) + + async def _async_update_data(self) -> BRouteData: + """Update data.""" + try: + return await self.hass.async_add_executor_job(self._get_data) + except MomongaError as error: + raise UpdateFailed(error) from error diff --git a/homeassistant/components/route_b_smart_meter/manifest.json b/homeassistant/components/route_b_smart_meter/manifest.json new file mode 100644 index 00000000000000..d1189d0a542024 --- /dev/null +++ b/homeassistant/components/route_b_smart_meter/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "route_b_smart_meter", + "name": "Smart Meter B Route", + "codeowners": ["@SeraphicRav"], + "config_flow": true, + "dependencies": ["usb"], + "documentation": "https://www.home-assistant.io/integrations/route_b_smart_meter", + "integration_type": "device", + "iot_class": "local_polling", + "loggers": [ + "momonga.momonga", + "momonga.momonga_session_manager", + "momonga.sk_wrapper_logger" + ], + "quality_scale": "bronze", + "requirements": ["pyserial==3.5", "momonga==0.1.5"] +} diff --git a/homeassistant/components/route_b_smart_meter/quality_scale.yaml b/homeassistant/components/route_b_smart_meter/quality_scale.yaml new file mode 100644 index 00000000000000..f6123b6e4c916e --- /dev/null +++ b/homeassistant/components/route_b_smart_meter/quality_scale.yaml @@ -0,0 +1,82 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + The integration does not provide any additional actions. + appropriate-polling: + status: done + brands: + status: exempt + comment: | + The integration is not specific to a single brand, it does not have a logo. + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + The integration does not provide any additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + The integration does not use events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + The integration does not provide any additional actions. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: + status: exempt + comment: | + The manufacturer does not use unique identifiers for devices. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: + status: exempt + comment: | + The integration does not use HTTP. + strict-typing: todo diff --git a/homeassistant/components/route_b_smart_meter/sensor.py b/homeassistant/components/route_b_smart_meter/sensor.py new file mode 100644 index 00000000000000..c8034528f5ac83 --- /dev/null +++ b/homeassistant/components/route_b_smart_meter/sensor.py @@ -0,0 +1,109 @@ +"""Smart Meter B Route.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfElectricCurrent, UnitOfEnergy, UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import BRouteConfigEntry +from .const import ( + ATTR_API_INSTANTANEOUS_CURRENT_R_PHASE, + ATTR_API_INSTANTANEOUS_CURRENT_T_PHASE, + ATTR_API_INSTANTANEOUS_POWER, + ATTR_API_TOTAL_CONSUMPTION, + DOMAIN, +) +from .coordinator import BRouteData, BRouteUpdateCoordinator + + +@dataclass(frozen=True, kw_only=True) +class SensorEntityDescriptionWithValueAccessor(SensorEntityDescription): + """Sensor entity description with data accessor.""" + + value_accessor: Callable[[BRouteData], StateType] + + +SENSOR_DESCRIPTIONS = ( + SensorEntityDescriptionWithValueAccessor( + key=ATTR_API_INSTANTANEOUS_CURRENT_R_PHASE, + translation_key=ATTR_API_INSTANTANEOUS_CURRENT_R_PHASE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_accessor=lambda data: data.instantaneous_current_r_phase, + ), + SensorEntityDescriptionWithValueAccessor( + key=ATTR_API_INSTANTANEOUS_CURRENT_T_PHASE, + translation_key=ATTR_API_INSTANTANEOUS_CURRENT_T_PHASE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_accessor=lambda data: data.instantaneous_current_t_phase, + ), + SensorEntityDescriptionWithValueAccessor( + key=ATTR_API_INSTANTANEOUS_POWER, + translation_key=ATTR_API_INSTANTANEOUS_POWER, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_accessor=lambda data: data.instantaneous_power, + ), + SensorEntityDescriptionWithValueAccessor( + key=ATTR_API_TOTAL_CONSUMPTION, + translation_key=ATTR_API_TOTAL_CONSUMPTION, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_accessor=lambda data: data.total_consumption, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BRouteConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Smart Meter B-route entry.""" + coordinator = entry.runtime_data + + async_add_entities( + SmartMeterBRouteSensor(coordinator, description) + for description in SENSOR_DESCRIPTIONS + ) + + +class SmartMeterBRouteSensor(CoordinatorEntity[BRouteUpdateCoordinator], SensorEntity): + """Representation of a Smart Meter B-route sensor entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: BRouteUpdateCoordinator, + description: SensorEntityDescriptionWithValueAccessor, + ) -> None: + """Initialize Smart Meter B-route sensor entity.""" + super().__init__(coordinator) + self.entity_description: SensorEntityDescriptionWithValueAccessor = description + self._attr_unique_id = f"{coordinator.bid}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.bid)}, + name=f"Route B Smart Meter {coordinator.bid}", + ) + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_accessor(self.coordinator.data) diff --git a/homeassistant/components/route_b_smart_meter/strings.json b/homeassistant/components/route_b_smart_meter/strings.json new file mode 100644 index 00000000000000..382ff6edaa0ab4 --- /dev/null +++ b/homeassistant/components/route_b_smart_meter/strings.json @@ -0,0 +1,42 @@ +{ + "config": { + "step": { + "user": { + "data_description": { + "device": "[%key:common::config_flow::data::device%]", + "id": "B Route ID", + "password": "[%key:common::config_flow::data::password%]" + }, + "data": { + "device": "[%key:common::config_flow::data::device%]", + "id": "B Route ID", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "sensor": { + "instantaneous_power": { + "name": "Instantaneous power" + }, + "total_consumption": { + "name": "Total consumption" + }, + "instantaneous_current_t_phase": { + "name": "Instantaneous current T phase" + }, + "instantaneous_current_r_phase": { + "name": "Instantaneous current R phase" + } + } + } +} diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index a47c05a735a42d..a21aca70d2ecab 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -26,6 +26,7 @@ ATTR_MEDIA_ARTIST, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_ENQUEUE, + ATTR_MEDIA_EXTRA, ATTR_MEDIA_TITLE, BrowseMedia, MediaPlayerDeviceClass, @@ -538,26 +539,14 @@ def _play_media( share_link = self.coordinator.share_link if share_link.is_share_link(media_id): - if enqueue == MediaPlayerEnqueue.ADD: - share_link.add_share_link_to_queue( - media_id, timeout=LONG_SERVICE_TIMEOUT - ) - elif enqueue in ( - MediaPlayerEnqueue.NEXT, - MediaPlayerEnqueue.PLAY, - ): - pos = (self.media.queue_position or 0) + 1 - new_pos = share_link.add_share_link_to_queue( - media_id, position=pos, timeout=LONG_SERVICE_TIMEOUT - ) - if enqueue == MediaPlayerEnqueue.PLAY: - soco.play_from_queue(new_pos - 1) - elif enqueue == MediaPlayerEnqueue.REPLACE: - soco.clear_queue() - share_link.add_share_link_to_queue( - media_id, timeout=LONG_SERVICE_TIMEOUT - ) - soco.play_from_queue(0) + title = kwargs.get(ATTR_MEDIA_EXTRA, {}).get("title", "") + self._play_media_sharelink( + soco=soco, + media_type=media_type, + media_id=media_id, + enqueue=enqueue, + title=title, + ) elif media_type == MEDIA_TYPE_DIRECTORY: self._play_media_directory( soco=soco, media_type=media_type, media_id=media_id, enqueue=enqueue @@ -663,6 +652,39 @@ def _play_media_directory( ) self._play_media_queue(soco, item, enqueue) + def _play_media_sharelink( + self, + soco: SoCo, + media_type: MediaType | str, + media_id: str, + enqueue: MediaPlayerEnqueue, + title: str, + ) -> None: + share_link = self.coordinator.share_link + kwargs = {} + if title: + kwargs["dc_title"] = title + if enqueue == MediaPlayerEnqueue.ADD: + share_link.add_share_link_to_queue( + media_id, timeout=LONG_SERVICE_TIMEOUT, **kwargs + ) + elif enqueue in ( + MediaPlayerEnqueue.NEXT, + MediaPlayerEnqueue.PLAY, + ): + pos = (self.media.queue_position or 0) + 1 + new_pos = share_link.add_share_link_to_queue( + media_id, position=pos, timeout=LONG_SERVICE_TIMEOUT, **kwargs + ) + if enqueue == MediaPlayerEnqueue.PLAY: + soco.play_from_queue(new_pos - 1) + elif enqueue == MediaPlayerEnqueue.REPLACE: + soco.clear_queue() + share_link.add_share_link_to_queue( + media_id, timeout=LONG_SERVICE_TIMEOUT, **kwargs + ) + soco.play_from_queue(0) + @soco_error() def set_sleep_timer(self, sleep_time: int) -> None: """Set the timer on the player.""" diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 15849494602888..b94530e432b011 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -157,13 +157,16 @@ class DeviceCategory(StrEnum): https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld """ CWYSJ = "cwysj" - """Pet fountain""" + """Pet fountain + + https://developer.tuya.com/en/docs/iot/categorycwysj?id=Kaiuz2dfro0nd + """ CZ = "cz" """Socket""" DBL = "dbl" """Electric fireplace - https://developer.tuya.com/en/docs/iot/f?id=Kacpeobojffop + https://developer.tuya.com/en/docs/iot/electric-fireplace?id=Kaiuz2hz4iyp6 """ DC = "dc" """String lights @@ -188,7 +191,10 @@ class DeviceCategory(StrEnum): https://developer.tuya.com/en/docs/iot/categorydj?id=Kaiuyzy3eheyy """ DLQ = "dlq" - """Circuit breaker""" + """Circuit breaker + + https://developer.tuya.com/en/docs/iot/dlq?id=Kb0kidk9enyh8 + """ DR = "dr" """Electric blanket @@ -212,7 +218,10 @@ class DeviceCategory(StrEnum): https://developer.tuya.com/en/docs/iot/ambient-light?id=Kaiuz06amhe6g """ GGQ = "ggq" - """Irrigator""" + """Irrigator + + https://developer.tuya.com/en/docs/iot/categoryggq?id=Kaiuz1qib7z0k + """ GYD = "gyd" """Motion sensor light @@ -307,7 +316,10 @@ class DeviceCategory(StrEnum): MS_CATEGORY = "ms_category" """Lock accessories""" MSP = "msp" - """Cat toilet""" + """Cat toilet + + https://developer.tuya.com/en/docs/iot/s?id=Kakg3srr4ora7 + """ MZJ = "mzj" """Sous vide cooker @@ -428,7 +440,10 @@ class DeviceCategory(StrEnum): XFJ = "xfj" """Ventilation system""" XXJ = "xxj" - """Diffuser""" + """Diffuser + + https://developer.tuya.com/en/docs/iot/categoryxxj?id=Kaiuz1f9mo6bl + """ XY = "xy" """Washing machine""" YB = "yb" @@ -456,7 +471,10 @@ class DeviceCategory(StrEnum): https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno """ ZNDB = "zndb" - """Smart electricity meter""" + """Smart electricity meter + + https://developer.tuya.com/en/docs/iot/smart-meter?id=Kaiuz4gv6ack7 + """ ZNFH = "znfh" """Bento box""" ZNSB = "znsb" @@ -465,6 +483,8 @@ class DeviceCategory(StrEnum): """Smart pill box""" # Undocumented + AQCZ = "aqcz" + """Single Phase power meter (undocumented)""" BZYD = "bzyd" """White noise machine (undocumented)""" CWJWQ = "cwjwq" @@ -486,6 +506,11 @@ class DeviceCategory(StrEnum): """ FSKG = "fskg" """Fan wall switch (undocumented)""" + HJJCY = "hjjcy" + """Air Quality Monitor + + https://developer.tuya.com/en/docs/iot/hjjcy?id=Kbeoad8y1nnlv + """ HXD = "hxd" """Wake Up Light II (undocumented)""" JDCLJQR = "jdcljqr" @@ -502,6 +527,8 @@ class DeviceCategory(StrEnum): Found as VECINO RGBW as provided by diagnostics """ + QCCDZ = "qccdz" + """AC charging (undocumented)""" QJDCZ = "qjdcz" """ Unknown product with light capabilities @@ -516,6 +543,8 @@ class DeviceCategory(StrEnum): """Smart Water Timer (undocumented)""" SJZ = "sjz" """Electric desk (undocumented)""" + SZJCY = "szjcy" + """Water tester (undocumented)""" SZJQR = "szjqr" """Fingerbot (undocumented)""" SWTZ = "swtz" @@ -531,8 +560,19 @@ class DeviceCategory(StrEnum): https://developer.tuya.com/en/docs/iot/wg?id=Kbcdadk79ejok """ + WKCZ = "wkcz" + """Two-way temperature and humidity switch (undocumented) + + "MOES Temperature and Humidity Smart Switch Module MS-103" + """ WKF = "wkf" """Thermostatic Radiator Valve (undocumented)""" + WNYKQ = "wnykq" + """Smart WiFi IR Remote (undocumented) + + eMylo Smart WiFi IR Remote + Air Conditioner Mate (Smart IR Socket) + """ WXKG = "wxkg" # Documented, but not in official list """Wireless Switch @@ -545,8 +585,14 @@ class DeviceCategory(StrEnum): """ YWCGQ = "ywcgq" """Tank Level Sensor (undocumented)""" + ZNNBQ = "znnbq" + """VESKA-micro inverter (undocumented)""" + ZWJCY = "zwjcy" + """Soil sensor - plant monitor (undocumented)""" + ZNJXS = "znjxs" + """Hejhome whitelabel Fingerbot (undocumented)""" ZNRB = "znrb" - """Pool HeatPump""" + """Pool HeatPump (undocumented)""" class DPCode(StrEnum): diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 3464b535c47448..16fa9f294ea6b5 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry -from .const import TUYA_DISCOVERY_NEW, DPCode, DPType +from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType from .entity import TuyaEntity from .models import EnumTypeData, IntegerTypeData from .util import get_dpcode @@ -40,10 +40,8 @@ class TuyaCoverEntityDescription(CoverEntityDescription): motor_reverse_mode: DPCode | None = None -COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { - # Garage Door Opener - # https://developer.tuya.com/en/docs/iot/categoryckmkzq?id=Kaiuz0ipcboee - "ckmkzq": ( +COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = { + DeviceCategory.CKMKZQ: ( TuyaCoverEntityDescription( key=DPCode.SWITCH_1, translation_key="indexed_door", @@ -69,10 +67,7 @@ class TuyaCoverEntityDescription(CoverEntityDescription): device_class=CoverDeviceClass.GARAGE, ), ), - # Curtain - # Note: Multiple curtains isn't documented - # https://developer.tuya.com/en/docs/iot/categorycl?id=Kaiuz1hnpo7df - "cl": ( + DeviceCategory.CL: ( TuyaCoverEntityDescription( key=DPCode.CONTROL, translation_key="curtain", @@ -117,9 +112,7 @@ class TuyaCoverEntityDescription(CoverEntityDescription): device_class=CoverDeviceClass.BLIND, ), ), - # Curtain Switch - # https://developer.tuya.com/en/docs/iot/category-clkg?id=Kaiuz0gitil39 - "clkg": ( + DeviceCategory.CLKG: ( TuyaCoverEntityDescription( key=DPCode.CONTROL, translation_key="curtain", @@ -138,9 +131,7 @@ class TuyaCoverEntityDescription(CoverEntityDescription): device_class=CoverDeviceClass.CURTAIN, ), ), - # Curtain Robot - # Note: Not documented - "jdcljqr": ( + DeviceCategory.JDCLJQR: ( TuyaCoverEntityDescription( key=DPCode.CONTROL, translation_key="curtain", diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 3851287ce46683..f00b034c8a2487 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -37,6 +37,7 @@ DOMAIN, LOGGER, TUYA_DISCOVERY_NEW, + DeviceCategory, DPCode, DPType, UnitOfMeasurement, @@ -115,11 +116,8 @@ class TuyaSensorEntityDescription(SensorEntityDescription): # All descriptions can be found here. Mostly the Integer data types in the # default status set of each category (that don't have a set instruction) # end up being a sensor. -# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq -SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { - # Single Phase power meter - # Note: Undocumented - "aqcz": ( +SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = { + DeviceCategory.AQCZ: ( TuyaSensorEntityDescription( key=DPCode.CUR_CURRENT, translation_key="current", @@ -144,9 +142,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), ), - # Smart Kettle - # https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7 - "bh": ( + DeviceCategory.BH: ( TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, translation_key="current_temperature", @@ -164,18 +160,14 @@ class TuyaSensorEntityDescription(SensorEntityDescription): translation_key="status", ), ), - # Curtain - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48qy7wkre - "cl": ( + DeviceCategory.CL: ( TuyaSensorEntityDescription( key=DPCode.TIME_TOTAL, translation_key="last_operation_duration", entity_category=EntityCategory.DIAGNOSTIC, ), ), - # CO2 Detector - # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy - "co2bj": ( + DeviceCategory.CO2BJ: ( TuyaSensorEntityDescription( key=DPCode.HUMIDITY_VALUE, translation_key="humidity", @@ -221,9 +213,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): ), *BATTERY_SENSORS, ), - # CO Detector - # https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v - "cobj": ( + DeviceCategory.COBJ: ( TuyaSensorEntityDescription( key=DPCode.CO_VALUE, translation_key="carbon_monoxide", @@ -233,9 +223,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): ), *BATTERY_SENSORS, ), - # Dehumidifier - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r6jke8e - "cs": ( + DeviceCategory.CS: ( TuyaSensorEntityDescription( key=DPCode.TEMP_INDOOR, translation_key="temperature", @@ -249,27 +237,21 @@ class TuyaSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, ), ), - # Smart Odor Eliminator-Pro - # Undocumented, see https://github.com/orgs/home-assistant/discussions/79 - "cwjwq": ( + DeviceCategory.CWJWQ: ( TuyaSensorEntityDescription( key=DPCode.WORK_STATE_E, translation_key="odor_elimination_status", ), *BATTERY_SENSORS, ), - # Smart Pet Feeder - # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld - "cwwsq": ( + DeviceCategory.CWWSQ: ( TuyaSensorEntityDescription( key=DPCode.FEED_REPORT, translation_key="last_amount", state_class=SensorStateClass.MEASUREMENT, ), ), - # Pet Fountain - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r0as4ln - "cwysj": ( + DeviceCategory.CWYSJ: ( TuyaSensorEntityDescription( key=DPCode.UV_RUNTIME, translation_key="uv_runtime", @@ -300,9 +282,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): key=DPCode.WATER_LEVEL, translation_key="water_level_state" ), ), - # Multi-functional Sensor - # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 - "dgnbj": ( + DeviceCategory.DGNBJ: ( TuyaSensorEntityDescription( key=DPCode.GAS_SENSOR_VALUE, translation_key="gas", @@ -376,9 +356,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): ), *BATTERY_SENSORS, ), - # Circuit Breaker - # https://developer.tuya.com/en/docs/iot/dlq?id=Kb0kidk9enyh8 - "dlq": ( + DeviceCategory.DLQ: ( TuyaSensorEntityDescription( key=DPCode.TOTAL_FORWARD_ENERGY, translation_key="total_energy", @@ -515,9 +493,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), ), - # Fan - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48quojr54 - "fs": ( + DeviceCategory.FS: ( TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, translation_key="temperature", @@ -525,12 +501,8 @@ class TuyaSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, ), ), - # Irrigator - # https://developer.tuya.com/en/docs/iot/categoryggq?id=Kaiuz1qib7z0k - "ggq": BATTERY_SENSORS, - # Air Quality Monitor - # https://developer.tuya.com/en/docs/iot/hjjcy?id=Kbeoad8y1nnlv - "hjjcy": ( + DeviceCategory.GGQ: BATTERY_SENSORS, + DeviceCategory.HJJCY: ( TuyaSensorEntityDescription( key=DPCode.AIR_QUALITY_INDEX, translation_key="air_quality_index", @@ -581,9 +553,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): ), *BATTERY_SENSORS, ), - # Formaldehyde Detector - # Note: Not documented - "jqbj": ( + DeviceCategory.JQBJ: ( TuyaSensorEntityDescription( key=DPCode.CO2_VALUE, translation_key="carbon_dioxide", @@ -623,9 +593,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): ), *BATTERY_SENSORS, ), - # Humidifier - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48qwjz0i3 - "jsq": ( + DeviceCategory.JSQ: ( TuyaSensorEntityDescription( key=DPCode.HUMIDITY_CURRENT, translation_key="humidity", @@ -650,9 +618,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, ), ), - # Methane Detector - # https://developer.tuya.com/en/docs/iot/categoryjwbj?id=Kaiuz40u98lkm - "jwbj": ( + DeviceCategory.JWBJ: ( TuyaSensorEntityDescription( key=DPCode.CH4_SENSOR_VALUE, translation_key="methane", @@ -660,9 +626,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): ), *BATTERY_SENSORS, ), - # Switch - # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s - "kg": ( + DeviceCategory.KG: ( TuyaSensorEntityDescription( key=DPCode.CUR_CURRENT, translation_key="current", @@ -699,9 +663,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.TOTAL_INCREASING, ), ), - # Air Purifier - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r41mn81 - "kj": ( + DeviceCategory.KJ: ( TuyaSensorEntityDescription( key=DPCode.FILTER, translation_key="filter_utilization", @@ -756,9 +718,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): translation_key="air_quality", ), ), - # Luminance Sensor - # https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8 - "ldcg": ( + DeviceCategory.LDCG: ( TuyaSensorEntityDescription( key=DPCode.BRIGHT_STATE, translation_key="luminosity", @@ -790,15 +750,9 @@ class TuyaSensorEntityDescription(SensorEntityDescription): ), *BATTERY_SENSORS, ), - # Door and Window Controller - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r5zjsy9 - "mc": BATTERY_SENSORS, - # Door Window Sensor - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48hm02l8m - "mcs": BATTERY_SENSORS, - # Cat toilet - # https://developer.tuya.com/en/docs/iot/s?id=Kakg3srr4ora7 - "msp": ( + DeviceCategory.MC: BATTERY_SENSORS, + DeviceCategory.MCS: BATTERY_SENSORS, + DeviceCategory.MSP: ( TuyaSensorEntityDescription( key=DPCode.CAT_WEIGHT, translation_key="cat_weight", @@ -806,9 +760,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, ), ), - # Sous Vide Cooker - # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux - "mzj": ( + DeviceCategory.MZJ: ( TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, translation_key="current_temperature", @@ -825,12 +777,8 @@ class TuyaSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfTime.MINUTES, ), ), - # PIR Detector - # https://developer.tuya.com/en/docs/iot/categorypir?id=Kaiuz3ss11b80 - "pir": BATTERY_SENSORS, - # PM2.5 Sensor - # https://developer.tuya.com/en/docs/iot/categorypm25?id=Kaiuz3qof3yfu - "pm2.5": ( + DeviceCategory.PIR: BATTERY_SENSORS, + DeviceCategory.PM2_5: ( TuyaSensorEntityDescription( key=DPCode.PM25_VALUE, translation_key="pm25", @@ -884,9 +832,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): ), *BATTERY_SENSORS, ), - # Heater - # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm - "qn": ( + DeviceCategory.QN: ( TuyaSensorEntityDescription( key=DPCode.WORK_POWER, translation_key="power", @@ -894,9 +840,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, ), ), - # Temperature and Humidity Sensor with External Probe - # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 - "qxj": ( + DeviceCategory.QXJ: ( TuyaSensorEntityDescription( key=DPCode.VA_TEMPERATURE, translation_key="temperature", @@ -1018,9 +962,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): ), *BATTERY_SENSORS, ), - # Gas Detector - # https://developer.tuya.com/en/docs/iot/categoryrqbj?id=Kaiuz3d162ubw - "rqbj": ( + DeviceCategory.RQBJ: ( TuyaSensorEntityDescription( key=DPCode.GAS_SENSOR_VALUE, name=None, @@ -1029,9 +971,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): ), *BATTERY_SENSORS, ), - # Robot Vacuum - # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo - "sd": ( + DeviceCategory.SD: ( TuyaSensorEntityDescription( key=DPCode.CLEAN_AREA, translation_key="cleaning_area", @@ -1085,8 +1025,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, ), ), - # Smart Water Timer - "sfkzq": ( + DeviceCategory.SFKZQ: ( # Total seconds of irrigation. Read-write value; the device appears to ignore the write action (maybe firmware bug) TuyaSensorEntityDescription( key=DPCode.TIME_USE, @@ -1096,18 +1035,10 @@ class TuyaSensorEntityDescription(SensorEntityDescription): ), *BATTERY_SENSORS, ), - # Siren Alarm - # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu - "sgbj": BATTERY_SENSORS, - # Water Detector - # https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli - "sj": BATTERY_SENSORS, - # Emergency Button - # https://developer.tuya.com/en/docs/iot/categorysos?id=Kaiuz3oi6agjy - "sos": BATTERY_SENSORS, - # Smart Camera - # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 - "sp": ( + DeviceCategory.SGBJ: BATTERY_SENSORS, + DeviceCategory.SJ: BATTERY_SENSORS, + DeviceCategory.SOS: BATTERY_SENSORS, + DeviceCategory.SP: ( TuyaSensorEntityDescription( key=DPCode.SENSOR_TEMPERATURE, translation_key="temperature", @@ -1128,8 +1059,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, ), ), - # Cooking thermometer - "swtz": ( + DeviceCategory.SWTZ: ( TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, translation_key="temperature", @@ -1145,9 +1075,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): ), *BATTERY_SENSORS, ), - # Smart Gardening system - # https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0 - "sz": ( + DeviceCategory.SZ: ( TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, translation_key="temperature", @@ -1161,8 +1089,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, ), ), - # Water tester - "szjcy": ( + DeviceCategory.SZJCY: ( TuyaSensorEntityDescription( key=DPCode.TDS_IN, translation_key="total_dissolved_solids", @@ -1176,11 +1103,8 @@ class TuyaSensorEntityDescription(SensorEntityDescription): ), *BATTERY_SENSORS, ), - # Fingerbot - "szjqr": BATTERY_SENSORS, - # IoT Switch - # Note: Undocumented - "tdq": ( + DeviceCategory.SZJQR: BATTERY_SENSORS, + DeviceCategory.TDQ: ( TuyaSensorEntityDescription( key=DPCode.CUR_CURRENT, translation_key="current", @@ -1242,12 +1166,8 @@ class TuyaSensorEntityDescription(SensorEntityDescription): ), *BATTERY_SENSORS, ), - # Solar Light - # https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98 - "tyndj": BATTERY_SENSORS, - # Volatile Organic Compound Sensor - # Note: Undocumented in cloud API docs, based on test device - "voc": ( + DeviceCategory.TYNDJ: BATTERY_SENSORS, + DeviceCategory.VOC: ( TuyaSensorEntityDescription( key=DPCode.CO2_VALUE, translation_key="carbon_dioxide", @@ -1287,13 +1207,8 @@ class TuyaSensorEntityDescription(SensorEntityDescription): ), *BATTERY_SENSORS, ), - # Thermostat - # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 - "wk": (*BATTERY_SENSORS,), - # Two-way temperature and humidity switch - # "MOES Temperature and Humidity Smart Switch Module MS-103" - # Documentation not found - "wkcz": ( + DeviceCategory.WK: (*BATTERY_SENSORS,), + DeviceCategory.WKCZ: ( TuyaSensorEntityDescription( key=DPCode.HUMIDITY_VALUE, translation_key="humidity", @@ -1330,12 +1245,8 @@ class TuyaSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), ), - # Thermostatic Radiator Valve - # Not documented - "wkf": BATTERY_SENSORS, - # eMylo Smart WiFi IR Remote - # Air Conditioner Mate (Smart IR Socket) - "wnykq": ( + DeviceCategory.WKF: BATTERY_SENSORS, + DeviceCategory.WNYKQ: ( TuyaSensorEntityDescription( key=DPCode.VA_TEMPERATURE, translation_key="temperature", @@ -1375,9 +1286,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), ), - # Temperature and Humidity Sensor - # https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 - "wsdcg": ( + DeviceCategory.WSDCG: ( TuyaSensorEntityDescription( key=DPCode.VA_TEMPERATURE, translation_key="temperature", @@ -1410,12 +1319,8 @@ class TuyaSensorEntityDescription(SensorEntityDescription): ), *BATTERY_SENSORS, ), - # Wireless Switch - # https://developer.tuya.com/en/docs/iot/s?id=Kbeoa9fkv6brp - "wxkg": BATTERY_SENSORS, # Pressure Sensor - # Micro Storage Inverter - # Energy storage and solar PV inverter system with monitoring capabilities - "xnyjcn": ( + DeviceCategory.WXKG: BATTERY_SENSORS, + DeviceCategory.XNYJCN: ( TuyaSensorEntityDescription( key=DPCode.CURRENT_SOC, translation_key="battery_soc", @@ -1486,8 +1391,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.TOTAL_INCREASING, ), ), - # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm - "ylcg": ( + DeviceCategory.YLCG: ( TuyaSensorEntityDescription( key=DPCode.PRESSURE_VALUE, name=None, @@ -1496,9 +1400,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): ), *BATTERY_SENSORS, ), - # Smoke Detector - # https://developer.tuya.com/en/docs/iot/categoryywbj?id=Kaiuz3f6sf952 - "ywbj": ( + DeviceCategory.YWBJ: ( TuyaSensorEntityDescription( key=DPCode.SMOKE_SENSOR_VALUE, translation_key="smoke_amount", @@ -1507,9 +1409,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): ), *BATTERY_SENSORS, ), - # Tank Level Sensor - # Note: Undocumented - "ywcgq": ( + DeviceCategory.YWCGQ: ( TuyaSensorEntityDescription( key=DPCode.LIQUID_STATE, translation_key="liquid_state", @@ -1526,12 +1426,8 @@ class TuyaSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, ), ), - # Vibration Sensor - # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno - "zd": BATTERY_SENSORS, - # Smart Electricity Meter - # https://developer.tuya.com/en/docs/iot/smart-meter?id=Kaiuz4gv6ack7 - "zndb": ( + DeviceCategory.ZD: BATTERY_SENSORS, + DeviceCategory.ZNDB: ( TuyaSensorEntityDescription( key=DPCode.FORWARD_ENERGY_TOTAL, translation_key="total_energy", @@ -1647,8 +1543,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): subkey="voltage", ), ), - # VESKA-micro inverter - "znnbq": ( + DeviceCategory.ZNNBQ: ( TuyaSensorEntityDescription( key=DPCode.REVERSE_ENERGY_TOTAL, translation_key="total_energy", @@ -1671,8 +1566,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, ), ), - # Pool HeatPump - "znrb": ( + DeviceCategory.ZNRB: ( TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, translation_key="temperature", @@ -1680,8 +1574,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, ), ), - # Soil sensor (Plant monitor) - "zwjcy": ( + DeviceCategory.ZWJCY: ( TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, translation_key="temperature", @@ -1699,16 +1592,13 @@ class TuyaSensorEntityDescription(SensorEntityDescription): } # Socket (duplicate of `kg`) -# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s -SENSORS["cz"] = SENSORS["kg"] +SENSORS[DeviceCategory.CZ] = SENSORS[DeviceCategory.KG] # Smart Camera - Low power consumption camera (duplicate of `sp`) -# Undocumented, see https://github.com/home-assistant/core/issues/132844 -SENSORS["dghsxj"] = SENSORS["sp"] +SENSORS[DeviceCategory.DGHSXJ] = SENSORS[DeviceCategory.SP] # Power Socket (duplicate of `kg`) -# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s -SENSORS["pc"] = SENSORS["kg"] +SENSORS[DeviceCategory.PC] = SENSORS[DeviceCategory.KG] async def async_setup_entry( diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index d34123e0271165..a12562b455fef3 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -25,7 +25,7 @@ ) from . import TuyaConfigEntry -from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode +from .const import DOMAIN, TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity @@ -40,10 +40,8 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): # All descriptions can be found here. Mostly the Boolean data types in the # default instruction set of each category end up being a Switch. # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq -SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { - # Smart Kettle - # https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7 - "bh": ( +SWITCHES: dict[DeviceCategory, tuple[SwitchEntityDescription, ...]] = { + DeviceCategory.BH: ( SwitchEntityDescription( key=DPCode.START, translation_key="start", @@ -54,8 +52,7 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): entity_category=EntityCategory.CONFIG, ), ), - # White noise machine - "bzyd": ( + DeviceCategory.BZYD: ( SwitchEntityDescription( key=DPCode.SWITCH, name=None, @@ -79,9 +76,7 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): entity_category=EntityCategory.CONFIG, ), ), - # Curtain - # https://developer.tuya.com/en/docs/iot/f?id=K9gf46o5mtfyc - "cl": ( + DeviceCategory.CL: ( SwitchEntityDescription( key=DPCode.CONTROL_BACK, translation_key="reverse", @@ -93,9 +88,7 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): entity_category=EntityCategory.CONFIG, ), ), - # EasyBaby - # Undocumented, might have a wider use - "cn": ( + DeviceCategory.CN: ( SwitchEntityDescription( key=DPCode.DISINFECTION, translation_key="disinfection", @@ -105,9 +98,7 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): translation_key="water", ), ), - # Dehumidifier - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r6jke8e - "cs": ( + DeviceCategory.CS: ( SwitchEntityDescription( key=DPCode.ANION, translation_key="ionizer", @@ -127,26 +118,20 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): entity_category=EntityCategory.CONFIG, ), ), - # Smart Odor Eliminator-Pro - # Undocumented, see https://github.com/orgs/home-assistant/discussions/79 - "cwjwq": ( + DeviceCategory.CWJWQ: ( SwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", ), ), - # Smart Pet Feeder - # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld - "cwwsq": ( + DeviceCategory.CWWSQ: ( SwitchEntityDescription( key=DPCode.SLOW_FEED, translation_key="slow_feed", entity_category=EntityCategory.CONFIG, ), ), - # Pet Fountain - # https://developer.tuya.com/en/docs/iot/f?id=K9gf46aewxem5 - "cwysj": ( + DeviceCategory.CWYSJ: ( SwitchEntityDescription( key=DPCode.FILTER_RESET, translation_key="filter_reset", @@ -172,9 +157,7 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): entity_category=EntityCategory.CONFIG, ), ), - # Light - # https://developer.tuya.com/en/docs/iot/f?id=K9i5ql3v98hn3 - "dj": ( + DeviceCategory.DJ: ( # There are sockets available with an RGB light # that advertise as `dj`, but provide an additional # switch to control the plug. @@ -183,8 +166,7 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): translation_key="plug", ), ), - # Circuit Breaker - "dlq": ( + DeviceCategory.DLQ: ( SwitchEntityDescription( key=DPCode.CHILD_LOCK, translation_key="child_lock", @@ -195,9 +177,7 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): translation_key="switch", ), ), - # Electric Blanket - # https://developer.tuya.com/en/docs/iot/categorydr?id=Kaiuz22dyc66p - "dr": ( + DeviceCategory.DR: ( SwitchEntityDescription( key=DPCode.SWITCH, name="Power", @@ -235,9 +215,7 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): device_class=SwitchDeviceClass.SWITCH, ), ), - # Fan - # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c - "fs": ( + DeviceCategory.FS: ( SwitchEntityDescription( key=DPCode.ANION, translation_key="anion", @@ -269,18 +247,14 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): entity_category=EntityCategory.CONFIG, ), ), - # Ceiling Fan Light - # https://developer.tuya.com/en/docs/iot/fsd?id=Kaof8eiei4c2v - "fsd": ( + DeviceCategory.FSD: ( SwitchEntityDescription( key=DPCode.FAN_BEEP, translation_key="sound", entity_category=EntityCategory.CONFIG, ), ), - # Irrigator - # https://developer.tuya.com/en/docs/iot/categoryggq?id=Kaiuz1qib7z0k - "ggq": ( + DeviceCategory.GGQ: ( SwitchEntityDescription( key=DPCode.SWITCH_1, translation_key="indexed_switch", @@ -322,9 +296,7 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): translation_placeholders={"index": "8"}, ), ), - # Wake Up Light II - # Not documented - "hxd": ( + DeviceCategory.HXD: ( SwitchEntityDescription( key=DPCode.SWITCH_1, translation_key="radio", @@ -358,9 +330,7 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): translation_key="sleep_aid", ), ), - # Humidifier - # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b - "jsq": ( + DeviceCategory.JSQ: ( SwitchEntityDescription( key=DPCode.SWITCH_SOUND, translation_key="voice", @@ -377,9 +347,7 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): entity_category=EntityCategory.CONFIG, ), ), - # Switch - # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s - "kg": ( + DeviceCategory.KG: ( SwitchEntityDescription( key=DPCode.CHILD_LOCK, translation_key="child_lock", @@ -469,9 +437,7 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): device_class=SwitchDeviceClass.OUTLET, ), ), - # Air Purifier - # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm - "kj": ( + DeviceCategory.KJ: ( SwitchEntityDescription( key=DPCode.ANION, translation_key="ionizer", @@ -502,9 +468,7 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): entity_category=EntityCategory.CONFIG, ), ), - # Air conditioner - # https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n - "kt": ( + DeviceCategory.KT: ( SwitchEntityDescription( key=DPCode.ANION, translation_key="ionizer", @@ -516,17 +480,13 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): entity_category=EntityCategory.CONFIG, ), ), - # Undocumented tower fan - # https://github.com/orgs/home-assistant/discussions/329 - "ks": ( + DeviceCategory.KS: ( SwitchEntityDescription( key=DPCode.ANION, translation_key="ionizer", ), ), - # Alarm Host - # https://developer.tuya.com/en/docs/iot/alarm-hosts?id=K9gf48r87hyjk - "mal": ( + DeviceCategory.MAL: ( SwitchEntityDescription( key=DPCode.SWITCH_ALARM_SOUND, # This switch is called "Arm Beep" in the official Tuya app @@ -540,9 +500,7 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): entity_category=EntityCategory.CONFIG, ), ), - # Sous Vide Cooker - # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux - "mzj": ( + DeviceCategory.MZJ: ( SwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", @@ -554,9 +512,7 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): entity_category=EntityCategory.CONFIG, ), ), - # Power Socket - # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s - "pc": ( + DeviceCategory.PC: ( SwitchEntityDescription( key=DPCode.CHILD_LOCK, translation_key="child_lock", @@ -634,26 +590,19 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): device_class=SwitchDeviceClass.OUTLET, ), ), - # AC charging - # Not documented - "qccdz": ( + DeviceCategory.QCCDZ: ( SwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", ), ), - # Unknown product with switch capabilities - # Fond in some diffusers, plugs and PIR flood lights - # Not documented - "qjdcz": ( + DeviceCategory.QJDCZ: ( SwitchEntityDescription( key=DPCode.SWITCH_1, translation_key="switch", ), ), - # Heater - # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm - "qn": ( + DeviceCategory.QN: ( SwitchEntityDescription( key=DPCode.ANION, translation_key="ionizer", @@ -665,18 +614,14 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): entity_category=EntityCategory.CONFIG, ), ), - # SIREN: Siren (switch) with Temperature and Humidity Sensor with External Probe - # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 - "qxj": ( + DeviceCategory.QXJ: ( SwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", device_class=SwitchDeviceClass.OUTLET, ), ), - # Robot Vacuum - # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo - "sd": ( + DeviceCategory.SD: ( SwitchEntityDescription( key=DPCode.SWITCH_DISTURB, translation_key="do_not_disturb", @@ -688,8 +633,7 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): entity_category=EntityCategory.CONFIG, ), ), - # Smart Water Timer - "sfkzq": ( + DeviceCategory.SFKZQ: ( TuyaDeprecatedSwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", @@ -697,26 +641,21 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): breaks_in_ha_version="2026.4.0", ), ), - # Siren Alarm - # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu - "sgbj": ( + DeviceCategory.SGBJ: ( SwitchEntityDescription( key=DPCode.MUFFLING, translation_key="mute", entity_category=EntityCategory.CONFIG, ), ), - # Electric desk - "sjz": ( + DeviceCategory.SJZ: ( SwitchEntityDescription( key=DPCode.CHILD_LOCK, translation_key="child_lock", entity_category=EntityCategory.CONFIG, ), ), - # Smart Camera - # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 - "sp": ( + DeviceCategory.SP: ( SwitchEntityDescription( key=DPCode.WIRELESS_BATTERYLOCK, translation_key="battery_lock", @@ -773,9 +712,7 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): entity_category=EntityCategory.CONFIG, ), ), - # Smart Gardening system - # https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0 - "sz": ( + DeviceCategory.SZ: ( SwitchEntityDescription( key=DPCode.SWITCH, translation_key="power", @@ -785,16 +722,13 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): translation_key="pump", ), ), - # Fingerbot - "szjqr": ( + DeviceCategory.SZJQR: ( SwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", ), ), - # IoT Switch? - # Note: Undocumented - "tdq": ( + DeviceCategory.TDQ: ( SwitchEntityDescription( key=DPCode.SWITCH_1, translation_key="indexed_switch", @@ -837,27 +771,21 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): entity_category=EntityCategory.CONFIG, ), ), - # Solar Light - # https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98 - "tyndj": ( + DeviceCategory.TYNDJ: ( SwitchEntityDescription( key=DPCode.SWITCH_SAVE_ENERGY, translation_key="energy_saving", entity_category=EntityCategory.CONFIG, ), ), - # Gateway control - # https://developer.tuya.com/en/docs/iot/wg?id=Kbcdadk79ejok - "wg2": ( + DeviceCategory.WG2: ( SwitchEntityDescription( key=DPCode.MUFFLING, translation_key="mute", entity_category=EntityCategory.CONFIG, ), ), - # Thermostat - # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 - "wk": ( + DeviceCategory.WK: ( SwitchEntityDescription( key=DPCode.CHILD_LOCK, translation_key="child_lock", @@ -869,10 +797,7 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): entity_category=EntityCategory.CONFIG, ), ), - # Two-way temperature and humidity switch - # "MOES Temperature and Humidity Smart Switch Module MS-103" - # Documentation not found - "wkcz": ( + DeviceCategory.WKCZ: ( SwitchEntityDescription( key=DPCode.SWITCH_1, translation_key="indexed_switch", @@ -886,9 +811,7 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): device_class=SwitchDeviceClass.OUTLET, ), ), - # Thermostatic Radiator Valve - # Not documented - "wkf": ( + DeviceCategory.WKF: ( SwitchEntityDescription( key=DPCode.CHILD_LOCK, translation_key="child_lock", @@ -900,43 +823,34 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): entity_category=EntityCategory.CONFIG, ), ), - # Air Conditioner Mate (Smart IR Socket) - "wnykq": ( + DeviceCategory.WNYKQ: ( SwitchEntityDescription( key=DPCode.SWITCH, name=None, ), ), - # SIREN: Siren (switch) with Temperature and humidity sensor - # https://developer.tuya.com/en/docs/iot/f?id=Kavck4sr3o5ek - "wsdcg": ( + DeviceCategory.WSDCG: ( SwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", device_class=SwitchDeviceClass.OUTLET, ), ), - # Ceiling Light - # https://developer.tuya.com/en/docs/iot/ceiling-light?id=Kaiuz03xxfc4r - "xdd": ( + DeviceCategory.XDD: ( SwitchEntityDescription( key=DPCode.DO_NOT_DISTURB, translation_key="do_not_disturb", entity_category=EntityCategory.CONFIG, ), ), - # Micro Storage Inverter - # Energy storage and solar PV inverter system with monitoring capabilities - "xnyjcn": ( + DeviceCategory.XNYJCN: ( SwitchEntityDescription( key=DPCode.FEEDIN_POWER_LIMIT_ENABLE, translation_key="output_power_limit", entity_category=EntityCategory.CONFIG, ), ), - # Diffuser - # https://developer.tuya.com/en/docs/iot/categoryxxj?id=Kaiuz1f9mo6bl - "xxj": ( + DeviceCategory.XXJ: ( SwitchEntityDescription( key=DPCode.SWITCH, translation_key="power", @@ -951,32 +865,26 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): entity_category=EntityCategory.CONFIG, ), ), - # Smoke Detector - # https://developer.tuya.com/en/docs/iot/categoryywbj?id=Kaiuz3f6sf952 - "ywbj": ( + DeviceCategory.YWBJ: ( SwitchEntityDescription( key=DPCode.MUFFLING, translation_key="mute", entity_category=EntityCategory.CONFIG, ), ), - # Smart Electricity Meter - # https://developer.tuya.com/en/docs/iot/smart-meter?id=Kaiuz4gv6ack7 - "zndb": ( + DeviceCategory.ZNDB: ( SwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", ), ), - # Hejhome whitelabel Fingerbot - "znjxs": ( + DeviceCategory.ZNJXS: ( SwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", ), ), - # Pool HeatPump - "znrb": ( + DeviceCategory.ZNRB: ( SwitchEntityDescription( key=DPCode.SWITCH, translation_key="switch", @@ -985,12 +893,10 @@ class TuyaDeprecatedSwitchEntityDescription(SwitchEntityDescription): } # Socket (duplicate of `pc`) -# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s -SWITCHES["cz"] = SWITCHES["pc"] +SWITCHES[DeviceCategory.CZ] = SWITCHES[DeviceCategory.PC] # Smart Camera - Low power consumption camera (duplicate of `sp`) -# Undocumented, see https://github.com/home-assistant/core/issues/132844 -SWITCHES["dghsxj"] = SWITCHES["sp"] +SWITCHES[DeviceCategory.DGHSXJ] = SWITCHES[DeviceCategory.SP] async def async_setup_entry( diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index cb0b26d6ac0a4d..5f90a3fc7d6e96 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -364,7 +364,7 @@ async def async_step_verify_radio( if user_input is not None or self._radio_mgr.radio_type in RECOMMENDED_RADIOS: # ZHA disables the single instance check and will decide at runtime if we # are migrating or setting up from scratch - if self.hass.config_entries.async_entries(DOMAIN): + if self.hass.config_entries.async_entries(DOMAIN, include_ignore=False): return await self.async_step_choose_migration_strategy() return await self.async_step_choose_setup_strategy() @@ -386,7 +386,7 @@ async def async_step_choose_setup_strategy( # Allow onboarding for new users to just create a new network automatically if ( not onboarding.async_is_onboarded(self.hass) - and not self.hass.config_entries.async_entries(DOMAIN) + and not self.hass.config_entries.async_entries(DOMAIN, include_ignore=False) and not self._radio_mgr.backups ): return await self.async_step_setup_strategy_recommended() @@ -438,12 +438,18 @@ async def async_step_maybe_reset_old_radio( """Erase the old radio's network settings before migration.""" # Like in the options flow, pull the correct settings from the config entry - config_entries = self.hass.config_entries.async_entries(DOMAIN) + config_entries = self.hass.config_entries.async_entries( + DOMAIN, include_ignore=False + ) if config_entries: assert len(config_entries) == 1 config_entry = config_entries[0] + # Unload ZHA before connecting to the old adapter + with suppress(OperationNotAllowed): + await self.hass.config_entries.async_unload(config_entry.entry_id) + # Create a radio manager to connect to the old stick to reset it temp_radio_mgr = ZhaRadioManager() temp_radio_mgr.hass = self.hass @@ -693,7 +699,9 @@ async def async_step_confirm( self._set_confirm_only() - zha_config_entries = self.hass.config_entries.async_entries(DOMAIN) + zha_config_entries = self.hass.config_entries.async_entries( + DOMAIN, include_ignore=False + ) # Without confirmation, discovery can automatically progress into parts of the # config flow logic that interacts with hardware. @@ -862,7 +870,9 @@ async def _async_create_radio_entry(self) -> ConfigFlowResult: # ZHA is still single instance only, even though we use discovery to allow for # migrating to a new radio - zha_config_entries = self.hass.config_entries.async_entries(DOMAIN) + zha_config_entries = self.hass.config_entries.async_entries( + DOMAIN, include_ignore=False + ) data = await self._get_config_entry_data() if len(zha_config_entries) == 1: diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 5cdff221957447..03b8f57c6eba1c 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -127,6 +127,7 @@ "coolmaster", "cpuspeed", "crownstone", + "cync", "daikin", "datadog", "deako", @@ -552,6 +553,7 @@ "romy", "roomba", "roon", + "route_b_smart_meter", "rova", "rpi_power", "ruckus_unleashed", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index fb4d3a1992158d..e260b37afe610e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1163,6 +1163,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "cync": { + "name": "Cync", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "dacia": { "name": "Dacia", "integration_type": "virtual", @@ -4168,7 +4174,7 @@ "name": "Manual MQTT Alarm Control Panel" }, "mqtt": { - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "local_push", "name": "MQTT" @@ -5637,6 +5643,12 @@ } } }, + "route_b_smart_meter": { + "name": "Smart Meter B Route", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "rova": { "name": "ROVA", "integration_type": "hub", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index afc46ecbd6bb39..36f01d11b69583 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==5.6.4 hass-nabucasa==1.1.1 hassil==3.2.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250903.5 +home-assistant-frontend==20250924.0 home-assistant-intents==2025.9.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/mypy.ini b/mypy.ini index 4bfe2a10063a06..dcf71efe8982e9 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4186,6 +4186,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.route_b_smart_meter.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.rpi_power.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 5500f3385a32fe..7d3421674a5244 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -247,7 +247,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==41.9.0 +aioesphomeapi==41.9.3 # homeassistant.components.flo aioflo==2021.11.0 @@ -1186,7 +1186,7 @@ hole==0.9.0 holidays==0.81 # homeassistant.components.frontend -home-assistant-frontend==20250903.5 +home-assistant-frontend==20250924.0 # homeassistant.components.conversation home-assistant-intents==2025.9.24 @@ -1466,6 +1466,9 @@ moat-ble==0.1.1 # homeassistant.components.moehlenhoff_alpha2 moehlenhoff-alpha2==1.4.0 +# homeassistant.components.route_b_smart_meter +momonga==0.1.5 + # homeassistant.components.monzo monzopy==1.5.1 @@ -1926,6 +1929,9 @@ pycsspeechtts==1.0.8 # homeassistant.components.cups # pycups==2.0.4 +# homeassistant.components.cync +pycync==0.4.0 + # homeassistant.components.daikin pydaikin==2.16.0 @@ -2345,6 +2351,7 @@ pyserial-asyncio-fast==0.16 # homeassistant.components.acer_projector # homeassistant.components.crownstone +# homeassistant.components.route_b_smart_meter # homeassistant.components.usb # homeassistant.components.zwave_js pyserial==3.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4f0bf24d867e72..bec1bb02bf2254 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -235,7 +235,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==41.9.0 +aioesphomeapi==41.9.3 # homeassistant.components.flo aioflo==2021.11.0 @@ -1035,7 +1035,7 @@ hole==0.9.0 holidays==0.81 # homeassistant.components.frontend -home-assistant-frontend==20250903.5 +home-assistant-frontend==20250924.0 # homeassistant.components.conversation home-assistant-intents==2025.9.24 @@ -1258,6 +1258,9 @@ moat-ble==0.1.1 # homeassistant.components.moehlenhoff_alpha2 moehlenhoff-alpha2==1.4.0 +# homeassistant.components.route_b_smart_meter +momonga==0.1.5 + # homeassistant.components.monzo monzopy==1.5.1 @@ -1619,6 +1622,9 @@ pycsspeechtts==1.0.8 # homeassistant.components.cups # pycups==2.0.4 +# homeassistant.components.cync +pycync==0.4.0 + # homeassistant.components.daikin pydaikin==2.16.0 @@ -1957,6 +1963,7 @@ pysensibo==1.2.1 # homeassistant.components.acer_projector # homeassistant.components.crownstone +# homeassistant.components.route_b_smart_meter # homeassistant.components.usb # homeassistant.components.zwave_js pyserial==3.5 diff --git a/tests/components/alexa_devices/conftest.py b/tests/components/alexa_devices/conftest.py index d9864fdeb31318..bed7abc3e33655 100644 --- a/tests/components/alexa_devices/conftest.py +++ b/tests/components/alexa_devices/conftest.py @@ -14,7 +14,7 @@ ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from .const import TEST_DEVICE, TEST_PASSWORD, TEST_SERIAL_NUMBER, TEST_USERNAME +from .const import TEST_DEVICE_1, TEST_DEVICE_1_SN, TEST_PASSWORD, TEST_USERNAME from tests.common import MockConfigEntry @@ -48,7 +48,7 @@ def mock_amazon_devices_client() -> Generator[AsyncMock]: CONF_SITE: "https://www.amazon.com", } client.get_devices_data.return_value = { - TEST_SERIAL_NUMBER: deepcopy(TEST_DEVICE) + TEST_DEVICE_1_SN: deepcopy(TEST_DEVICE_1) } client.get_model_details = lambda device: DEVICE_TYPE_TO_MODEL.get( device.device_type diff --git a/tests/components/alexa_devices/const.py b/tests/components/alexa_devices/const.py index fa30226849eed6..d078e92199ed31 100644 --- a/tests/components/alexa_devices/const.py +++ b/tests/components/alexa_devices/const.py @@ -4,20 +4,19 @@ TEST_CODE = "023123" TEST_PASSWORD = "fake_password" -TEST_SERIAL_NUMBER = "echo_test_serial_number" TEST_USERNAME = "fake_email@gmail.com" -TEST_DEVICE_ID = "echo_test_device_id" - -TEST_DEVICE = AmazonDevice( +TEST_DEVICE_1_SN = "echo_test_serial_number" +TEST_DEVICE_1_ID = "echo_test_device_id" +TEST_DEVICE_1 = AmazonDevice( account_name="Echo Test", capabilities=["AUDIO_PLAYER", "MICROPHONE"], device_family="mine", device_type="echo", device_owner_customer_id="amazon_ower_id", - device_cluster_members=[TEST_SERIAL_NUMBER], + device_cluster_members=[TEST_DEVICE_1_SN], online=True, - serial_number=TEST_SERIAL_NUMBER, + serial_number=TEST_DEVICE_1_SN, software_version="echo_test_software_version", do_not_disturb=False, response_style=None, @@ -30,3 +29,27 @@ ) }, ) + +TEST_DEVICE_2_SN = "echo_test_2_serial_number" +TEST_DEVICE_2_ID = "echo_test_2_device_id" +TEST_DEVICE_2 = AmazonDevice( + account_name="Echo Test 2", + capabilities=["AUDIO_PLAYER", "MICROPHONE"], + device_family="mine", + device_type="echo", + device_owner_customer_id="amazon_ower_id", + device_cluster_members=[TEST_DEVICE_2_SN], + online=True, + serial_number=TEST_DEVICE_2_SN, + software_version="echo_test_2_software_version", + do_not_disturb=False, + response_style=None, + bluetooth_state=True, + entity_id="11111111-2222-3333-4444-555555555555", + appliance_id="G1234567890123456789012345678A", + sensors={ + "temperature": AmazonDeviceSensor( + name="temperature", value="22.5", scale="CELSIUS" + ) + }, +) diff --git a/tests/components/alexa_devices/test_binary_sensor.py b/tests/components/alexa_devices/test_binary_sensor.py index a2e38b3459b1ed..bcb89664da46ff 100644 --- a/tests/components/alexa_devices/test_binary_sensor.py +++ b/tests/components/alexa_devices/test_binary_sensor.py @@ -17,7 +17,7 @@ from homeassistant.helpers import entity_registry as er from . import setup_integration -from .const import TEST_SERIAL_NUMBER +from .const import TEST_DEVICE_1, TEST_DEVICE_1_SN, TEST_DEVICE_2, TEST_DEVICE_2_SN from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -83,7 +83,7 @@ async def test_offline_device( entity_id = "binary_sensor.echo_test_connectivity" mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].online = False await setup_integration(hass, mock_config_entry) @@ -92,7 +92,7 @@ async def test_offline_device( assert state.state == STATE_UNAVAILABLE mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].online = True freezer.tick(SCAN_INTERVAL) @@ -101,3 +101,39 @@ async def test_offline_device( assert (state := hass.states.get(entity_id)) assert state.state != STATE_UNAVAILABLE + + +async def test_dynamic_device( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test device added dynamically.""" + + entity_id_1 = "binary_sensor.echo_test_connectivity" + entity_id_2 = "binary_sensor.echo_test_2_connectivity" + + mock_amazon_devices_client.get_devices_data.return_value = { + TEST_DEVICE_1_SN: TEST_DEVICE_1, + } + + await setup_integration(hass, mock_config_entry) + + assert (state := hass.states.get(entity_id_1)) + assert state.state == STATE_ON + + mock_amazon_devices_client.get_devices_data.return_value = { + TEST_DEVICE_1_SN: TEST_DEVICE_1, + TEST_DEVICE_2_SN: TEST_DEVICE_2, + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id_1)) + assert state.state == STATE_ON + + assert (state := hass.states.get(entity_id_2)) + assert state.state == STATE_ON diff --git a/tests/components/alexa_devices/test_coordinator.py b/tests/components/alexa_devices/test_coordinator.py index 3768404f871794..3e0880fcd0782f 100644 --- a/tests/components/alexa_devices/test_coordinator.py +++ b/tests/components/alexa_devices/test_coordinator.py @@ -2,7 +2,6 @@ from unittest.mock import AsyncMock -from aioamazondevices.api import AmazonDevice, AmazonDeviceSensor from freezegun.api import FrozenDateTimeFactory from homeassistant.components.alexa_devices.coordinator import SCAN_INTERVAL @@ -10,7 +9,7 @@ from homeassistant.core import HomeAssistant from . import setup_integration -from .const import TEST_DEVICE, TEST_SERIAL_NUMBER +from .const import TEST_DEVICE_1, TEST_DEVICE_1_SN, TEST_DEVICE_2, TEST_DEVICE_2_SN from tests.common import MockConfigEntry, async_fire_time_changed @@ -27,28 +26,8 @@ async def test_coordinator_stale_device( entity_id_1 = "binary_sensor.echo_test_2_connectivity" mock_amazon_devices_client.get_devices_data.return_value = { - TEST_SERIAL_NUMBER: TEST_DEVICE, - "echo_test_2_serial_number_2": AmazonDevice( - account_name="Echo Test 2", - capabilities=["AUDIO_PLAYER", "MICROPHONE"], - device_family="mine", - device_type="echo", - device_owner_customer_id="amazon_ower_id", - device_cluster_members=["echo_test_2_serial_number_2"], - online=True, - serial_number="echo_test_2_serial_number_2", - software_version="echo_test_2_software_version", - do_not_disturb=False, - response_style=None, - bluetooth_state=True, - entity_id="11111111-2222-3333-4444-555555555555", - appliance_id="G1234567890123456789012345678A", - sensors={ - "temperature": AmazonDeviceSensor( - name="temperature", value="22.5", scale="CELSIUS" - ) - }, - ), + TEST_DEVICE_1_SN: TEST_DEVICE_1, + TEST_DEVICE_2_SN: TEST_DEVICE_2, } await setup_integration(hass, mock_config_entry) @@ -59,7 +38,7 @@ async def test_coordinator_stale_device( assert state.state == STATE_ON mock_amazon_devices_client.get_devices_data.return_value = { - TEST_SERIAL_NUMBER: TEST_DEVICE, + TEST_DEVICE_1_SN: TEST_DEVICE_1, } freezer.tick(SCAN_INTERVAL) diff --git a/tests/components/alexa_devices/test_diagnostics.py b/tests/components/alexa_devices/test_diagnostics.py index 3c18d432543872..6c7a6ef4a81e42 100644 --- a/tests/components/alexa_devices/test_diagnostics.py +++ b/tests/components/alexa_devices/test_diagnostics.py @@ -12,7 +12,7 @@ from homeassistant.helpers import device_registry as dr from . import setup_integration -from .const import TEST_SERIAL_NUMBER +from .const import TEST_DEVICE_1_SN from tests.common import MockConfigEntry from tests.components.diagnostics import ( @@ -54,9 +54,7 @@ async def test_device_diagnostics( """Test Amazon device diagnostics.""" await setup_integration(hass, mock_config_entry) - device = device_registry.async_get_device( - identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} - ) + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_DEVICE_1_SN)}) assert device, repr(device_registry.devices) assert await get_diagnostics_for_device( diff --git a/tests/components/alexa_devices/test_init.py b/tests/components/alexa_devices/test_init.py index 328654682e911b..0b20b1fe239e7d 100644 --- a/tests/components/alexa_devices/test_init.py +++ b/tests/components/alexa_devices/test_init.py @@ -16,7 +16,7 @@ from homeassistant.helpers import device_registry as dr from . import setup_integration -from .const import TEST_PASSWORD, TEST_SERIAL_NUMBER, TEST_USERNAME +from .const import TEST_DEVICE_1_SN, TEST_PASSWORD, TEST_USERNAME from tests.common import MockConfigEntry @@ -31,7 +31,7 @@ async def test_device_info( """Test device registry integration.""" await setup_integration(hass, mock_config_entry) device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} + identifiers={(DOMAIN, TEST_DEVICE_1_SN)} ) assert device_entry is not None assert device_entry == snapshot diff --git a/tests/components/alexa_devices/test_notify.py b/tests/components/alexa_devices/test_notify.py index 6067874e370638..eafea4b525c32b 100644 --- a/tests/components/alexa_devices/test_notify.py +++ b/tests/components/alexa_devices/test_notify.py @@ -18,7 +18,7 @@ from homeassistant.util import dt as dt_util from . import setup_integration -from .const import TEST_SERIAL_NUMBER +from .const import TEST_DEVICE_1_SN from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -83,7 +83,7 @@ async def test_offline_device( entity_id = "notify.echo_test_announce" mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].online = False await setup_integration(hass, mock_config_entry) @@ -92,7 +92,7 @@ async def test_offline_device( assert state.state == STATE_UNAVAILABLE mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].online = True freezer.tick(SCAN_INTERVAL) diff --git a/tests/components/alexa_devices/test_sensor.py b/tests/components/alexa_devices/test_sensor.py index e8875fe08a4473..560a7e10b90d66 100644 --- a/tests/components/alexa_devices/test_sensor.py +++ b/tests/components/alexa_devices/test_sensor.py @@ -19,7 +19,7 @@ from homeassistant.helpers import entity_registry as er from . import setup_integration -from .const import TEST_SERIAL_NUMBER +from .const import TEST_DEVICE_1_SN from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -83,7 +83,7 @@ async def test_offline_device( entity_id = "sensor.echo_test_temperature" mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].online = False await setup_integration(hass, mock_config_entry) @@ -92,7 +92,7 @@ async def test_offline_device( assert state.state == STATE_UNAVAILABLE mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].online = True freezer.tick(SCAN_INTERVAL) @@ -133,7 +133,7 @@ async def test_unit_of_measurement( entity_id = f"sensor.echo_test_{sensor}" mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].sensors = {sensor: AmazonDeviceSensor(name=sensor, value=api_value, scale=scale)} await setup_integration(hass, mock_config_entry) diff --git a/tests/components/alexa_devices/test_services.py b/tests/components/alexa_devices/test_services.py index 72cef62a96623c..9ea1a271a7f06b 100644 --- a/tests/components/alexa_devices/test_services.py +++ b/tests/components/alexa_devices/test_services.py @@ -19,7 +19,7 @@ from homeassistant.helpers import device_registry as dr from . import setup_integration -from .const import TEST_DEVICE_ID, TEST_SERIAL_NUMBER +from .const import TEST_DEVICE_1_ID, TEST_DEVICE_1_SN from tests.common import MockConfigEntry, mock_device_registry @@ -49,7 +49,7 @@ async def test_send_sound_service( await setup_integration(hass, mock_config_entry) device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} + identifiers={(DOMAIN, TEST_DEVICE_1_SN)} ) assert device_entry @@ -79,7 +79,7 @@ async def test_send_text_service( await setup_integration(hass, mock_config_entry) device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} + identifiers={(DOMAIN, TEST_DEVICE_1_SN)} ) assert device_entry @@ -108,7 +108,7 @@ async def test_send_text_service( ), ( "wrong_sound_name", - TEST_DEVICE_ID, + TEST_DEVICE_1_ID, "invalid_sound_value", { "sound": "wrong_sound_name", @@ -128,7 +128,7 @@ async def test_invalid_parameters( """Test invalid service parameters.""" device_entry = dr.DeviceEntry( - id=TEST_DEVICE_ID, identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} + id=TEST_DEVICE_1_ID, identifiers={(DOMAIN, TEST_DEVICE_1_SN)} ) mock_device_registry( hass, @@ -164,7 +164,7 @@ async def test_config_entry_not_loaded( await setup_integration(hass, mock_config_entry) device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} + identifiers={(DOMAIN, TEST_DEVICE_1_SN)} ) assert device_entry diff --git a/tests/components/alexa_devices/test_switch.py b/tests/components/alexa_devices/test_switch.py index 26a18fb731a702..c5039d68da25a7 100644 --- a/tests/components/alexa_devices/test_switch.py +++ b/tests/components/alexa_devices/test_switch.py @@ -23,7 +23,7 @@ from homeassistant.helpers import entity_registry as er from . import setup_integration -from .conftest import TEST_SERIAL_NUMBER +from .conftest import TEST_DEVICE_1_SN from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -67,7 +67,7 @@ async def test_switch_dnd( assert mock_amazon_devices_client.set_do_not_disturb.call_count == 1 mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].do_not_disturb = True freezer.tick(SCAN_INTERVAL) @@ -85,7 +85,7 @@ async def test_switch_dnd( ) mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].do_not_disturb = False freezer.tick(SCAN_INTERVAL) @@ -108,7 +108,7 @@ async def test_offline_device( entity_id = "switch.echo_test_do_not_disturb" mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].online = False await setup_integration(hass, mock_config_entry) @@ -117,7 +117,7 @@ async def test_offline_device( assert state.state == STATE_UNAVAILABLE mock_amazon_devices_client.get_devices_data.return_value[ - TEST_SERIAL_NUMBER + TEST_DEVICE_1_SN ].online = True freezer.tick(SCAN_INTERVAL) diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index 4a98d9770e4fe7..876e34dae75e80 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -1232,34 +1232,25 @@ async def test_devices_payload_with_entities( "entities": [ { "assumed_state": None, - "capabilities": { - "min_color_temp_kelvin": 2000, - "max_color_temp_kelvin": 6535, - }, "domain": "light", "entity_category": None, "has_entity_name": True, - "modified_by_integration": None, "original_device_class": None, "unit_of_measurement": None, }, { "assumed_state": False, - "capabilities": None, "domain": "number", "entity_category": "config", "has_entity_name": True, - "modified_by_integration": None, "original_device_class": "temperature", "unit_of_measurement": None, }, { "assumed_state": True, - "capabilities": None, "domain": "light", "entity_category": None, "has_entity_name": True, - "modified_by_integration": None, "original_device_class": None, "unit_of_measurement": None, }, @@ -1277,11 +1268,9 @@ async def test_devices_payload_with_entities( "entities": [ { "assumed_state": None, - "capabilities": None, "domain": "light", "entity_category": None, "has_entity_name": False, - "modified_by_integration": None, "original_device_class": None, "unit_of_measurement": None, }, @@ -1299,11 +1288,9 @@ async def test_devices_payload_with_entities( "entities": [ { "assumed_state": None, - "capabilities": {"state_class": "measurement"}, "domain": "sensor", "entity_category": None, "has_entity_name": False, - "modified_by_integration": None, "original_device_class": "temperature", "unit_of_measurement": "°C", }, @@ -1314,11 +1301,9 @@ async def test_devices_payload_with_entities( "entities": [ { "assumed_state": None, - "capabilities": None, "domain": "light", "entity_category": None, "has_entity_name": True, - "modified_by_integration": None, "original_device_class": None, "unit_of_measurement": None, }, @@ -1427,11 +1412,9 @@ async def async_modify_analytics( "entities": [ { "assumed_state": None, - "capabilities": {"options": 2}, "domain": "sensor", "entity_category": None, "has_entity_name": False, - "modified_by_integration": ["capabilities"], "original_device_class": None, "unit_of_measurement": None, }, diff --git a/tests/components/comelit/test_cover.py b/tests/components/comelit/test_cover.py index 5513f3c4e2561a..02efff1dd94a54 100644 --- a/tests/components/comelit/test_cover.py +++ b/tests/components/comelit/test_cover.py @@ -193,3 +193,53 @@ async def test_cover_restore_state( assert (state := hass.states.get(ENTITY_ID)) assert state.state == STATE_OPENING + + +async def test_cover_dynamic( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test cover dynamically added.""" + + mock_serial_bridge.reset_mock() + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert hass.states.get(ENTITY_ID) + + entity_id_2 = "cover.cover1" + + mock_serial_bridge.get_all_devices.return_value[COVER] = { + 0: ComelitSerialBridgeObject( + index=0, + name="Cover0", + status=0, + human_status="stopped", + type="cover", + val=0, + protected=0, + zone="Open space", + power=0.0, + power_unit=WATT, + ), + 1: ComelitSerialBridgeObject( + index=1, + name="Cover1", + status=0, + human_status="stopped", + type="cover", + val=0, + protected=0, + zone="Open space", + power=0.0, + power_unit=WATT, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_ID) + assert hass.states.get(entity_id_2) diff --git a/tests/components/comelit/test_light.py b/tests/components/comelit/test_light.py index 36a191c9ee3de4..af2ff22a380e59 100644 --- a/tests/components/comelit/test_light.py +++ b/tests/components/comelit/test_light.py @@ -2,9 +2,13 @@ from unittest.mock import AsyncMock, patch +from aiocomelit.api import ComelitSerialBridgeObject +from aiocomelit.const import LIGHT, WATT +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.comelit.const import SCAN_INTERVAL from homeassistant.components.light import ( DOMAIN as LIGHT_DOMAIN, SERVICE_TOGGLE, @@ -17,7 +21,7 @@ from . import setup_integration -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform ENTITY_ID = "light.light0" @@ -74,3 +78,53 @@ async def test_light_set_state( assert (state := hass.states.get(ENTITY_ID)) assert state.state == status + + +async def test_light_dynamic( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test light dynamically added.""" + + mock_serial_bridge.reset_mock() + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert hass.states.get(ENTITY_ID) + + entity_id_2 = "light.light1" + + mock_serial_bridge.get_all_devices.return_value[LIGHT] = { + 0: ComelitSerialBridgeObject( + index=0, + name="Light0", + status=0, + human_status="stopped", + type="light", + val=0, + protected=0, + zone="Open space", + power=0.0, + power_unit=WATT, + ), + 1: ComelitSerialBridgeObject( + index=1, + name="Light1", + status=0, + human_status="stopped", + type="light", + val=0, + protected=0, + zone="Open space", + power=0.0, + power_unit=WATT, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_ID) + assert hass.states.get(entity_id_2) diff --git a/tests/components/comelit/test_sensor.py b/tests/components/comelit/test_sensor.py index 1bf717ca894fc4..eb9adc0d81ed31 100644 --- a/tests/components/comelit/test_sensor.py +++ b/tests/components/comelit/test_sensor.py @@ -2,8 +2,13 @@ from unittest.mock import AsyncMock, patch -from aiocomelit.api import AlarmDataObject, ComelitVedoAreaObject, ComelitVedoZoneObject -from aiocomelit.const import AlarmAreaState, AlarmZoneState +from aiocomelit.api import ( + AlarmDataObject, + ComelitSerialBridgeObject, + ComelitVedoAreaObject, + ComelitVedoZoneObject, +) +from aiocomelit.const import OTHER, WATT, AlarmAreaState, AlarmZoneState from freezegun.api import FrozenDateTimeFactory from syrupy.assertion import SnapshotAssertion @@ -44,7 +49,7 @@ async def test_sensor_state_unknown( mock_vedo: AsyncMock, mock_vedo_config_entry: MockConfigEntry, ) -> None: - """Test sensor unknown state.""" + """Test VEDO sensor unknown state.""" await setup_integration(hass, mock_vedo_config_entry) @@ -88,3 +93,93 @@ async def test_sensor_state_unknown( assert (state := hass.states.get(ENTITY_ID)) assert state.state == STATE_UNKNOWN + + +async def test_serial_bridge_sensor_dynamic( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test Serial Bridge sensor dynamically added.""" + + mock_serial_bridge.reset_mock() + await setup_integration(hass, mock_serial_bridge_config_entry) + + entity_id = "sensor.switch0" + entity_id_2 = "sensor.switch1" + assert hass.states.get(entity_id) + + mock_serial_bridge.get_all_devices.return_value[OTHER] = { + 0: ComelitSerialBridgeObject( + index=0, + name="Switch0", + status=0, + human_status="off", + type="other", + val=0, + protected=0, + zone="Bathroom", + power=0.0, + power_unit=WATT, + ), + 1: ComelitSerialBridgeObject( + index=1, + name="Switch1", + status=0, + human_status="off", + type="other", + val=0, + protected=0, + zone="Bathroom", + power=0.0, + power_unit=WATT, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id) + assert hass.states.get(entity_id_2) + + +async def test_vedo_sensor_dynamic( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, +) -> None: + """Test VEDO sensor dynamically added.""" + + mock_vedo.reset_mock() + await setup_integration(hass, mock_vedo_config_entry) + + assert hass.states.get(ENTITY_ID) + + entity_id_2 = "sensor.zone1" + + mock_vedo.get_all_areas_and_zones.return_value["alarm_zones"] = { + 0: ComelitVedoZoneObject( + index=0, + name="Zone0", + status_api="0x000", + status=0, + human_status=AlarmZoneState.REST, + ), + 1: ComelitVedoZoneObject( + index=1, + name="Zone1", + status_api="0x000", + status=0, + human_status=AlarmZoneState.REST, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_ID) + assert hass.states.get(entity_id_2) diff --git a/tests/components/comelit/test_switch.py b/tests/components/comelit/test_switch.py index 31a4c4b144c845..38955bfad40e5b 100644 --- a/tests/components/comelit/test_switch.py +++ b/tests/components/comelit/test_switch.py @@ -2,9 +2,13 @@ from unittest.mock import AsyncMock, patch +from aiocomelit.api import ComelitSerialBridgeObject +from aiocomelit.const import IRRIGATION, WATT +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.comelit.const import SCAN_INTERVAL from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TOGGLE, @@ -17,7 +21,7 @@ from . import setup_integration -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform ENTITY_ID = "switch.switch0" @@ -74,3 +78,53 @@ async def test_switch_set_state( assert (state := hass.states.get(ENTITY_ID)) assert state.state == status + + +async def test_switch_dynamic( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, +) -> None: + """Test switch dynamically added.""" + + mock_serial_bridge.reset_mock() + await setup_integration(hass, mock_serial_bridge_config_entry) + + entity_id = "switch.switch0" + entity_id_2 = "switch.switch1" + assert hass.states.get(entity_id) + + mock_serial_bridge.get_all_devices.return_value[IRRIGATION] = { + 0: ComelitSerialBridgeObject( + index=0, + name="Switch0", + status=0, + human_status="off", + type="irrigation", + val=0, + protected=0, + zone="Terrace", + power=0.0, + power_unit=WATT, + ), + 1: ComelitSerialBridgeObject( + index=1, + name="Switch1", + status=0, + human_status="off", + type="irrigation", + val=0, + protected=0, + zone="Terrace", + power=0.0, + power_unit=WATT, + ), + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id) + assert hass.states.get(entity_id_2) diff --git a/tests/components/cync/__init__.py b/tests/components/cync/__init__.py new file mode 100644 index 00000000000000..56cab084f998e0 --- /dev/null +++ b/tests/components/cync/__init__.py @@ -0,0 +1,15 @@ +"""Tests for the Cync integration.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Sets up the Cync integration to be used in testing.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/cync/conftest.py b/tests/components/cync/conftest.py new file mode 100644 index 00000000000000..2ea6e352a75bb9 --- /dev/null +++ b/tests/components/cync/conftest.py @@ -0,0 +1,91 @@ +"""Common fixtures for the Cync tests.""" + +from collections.abc import Generator +import time +from unittest.mock import AsyncMock, patch + +from pycync import Cync, CyncHome +import pytest + +from homeassistant.components.cync.const import ( + CONF_AUTHORIZE_STRING, + CONF_EXPIRES_AT, + CONF_REFRESH_TOKEN, + CONF_USER_ID, + DOMAIN, +) +from homeassistant.const import CONF_ACCESS_TOKEN + +from .const import MOCKED_EMAIL, MOCKED_USER + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture(autouse=True) +def auth_client(): + """Mock a pycync.Auth client.""" + with patch( + "homeassistant.components.cync.config_flow.Auth", autospec=True + ) as sc_class_mock: + client_mock = sc_class_mock.return_value + client_mock.user = MOCKED_USER + client_mock.username = MOCKED_EMAIL + yield client_mock + + +@pytest.fixture(autouse=True) +def cync_client(): + """Mock a pycync.Cync client.""" + with ( + patch( + "homeassistant.components.cync.coordinator.Cync", + spec=Cync, + ) as cync_mock, + patch( + "homeassistant.components.cync.Cync", + new=cync_mock, + ), + ): + cync_mock.get_logged_in_user.return_value = MOCKED_USER + + home_fixture: CyncHome = CyncHome.from_dict( + load_json_object_fixture("home.json", DOMAIN) + ) + cync_mock.get_homes.return_value = [home_fixture] + + available_mock_devices = [ + device + for device in home_fixture.get_flattened_device_list() + if device.is_online + ] + cync_mock.get_devices.return_value = available_mock_devices + + cync_mock.create.return_value = cync_mock + client_mock = cync_mock.return_value + yield client_mock + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.cync.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a Cync config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title=MOCKED_EMAIL, + unique_id=str(MOCKED_USER.user_id), + data={ + CONF_USER_ID: MOCKED_USER.user_id, + CONF_AUTHORIZE_STRING: "test_authorize_string", + CONF_EXPIRES_AT: (time.time() * 1000) + 3600000, + CONF_ACCESS_TOKEN: "test_token", + CONF_REFRESH_TOKEN: "test_refresh_token", + }, + ) diff --git a/tests/components/cync/const.py b/tests/components/cync/const.py new file mode 100644 index 00000000000000..79f7e8b8b21531 --- /dev/null +++ b/tests/components/cync/const.py @@ -0,0 +1,14 @@ +"""Test constants used in Cync tests.""" + +import time + +import pycync + +MOCKED_USER = pycync.User( + "test_token", + "test_refresh_token", + "test_authorize_string", + 123456789, + expires_at=(time.time() * 1000) + 3600000, +) +MOCKED_EMAIL = "test@testuser.com" diff --git a/tests/components/cync/fixtures/home.json b/tests/components/cync/fixtures/home.json new file mode 100644 index 00000000000000..22e009de965061 --- /dev/null +++ b/tests/components/cync/fixtures/home.json @@ -0,0 +1,76 @@ +{ + "name": "My Home", + "home_id": 1000, + "rooms": [ + { + "name": "Bedroom", + "room_id": 1100, + "home_id": 1000, + "groups": [], + "devices": [ + { + "name": "Bedroom Lamp", + "is_online": true, + "wifi_connected": true, + "device_id": 1101, + "mesh_device_id": 10001, + "home_id": 1000, + "device_type_id": 137, + "device_type": "LIGHT", + "mac_address": "ABCDEF123456", + "product_id": "product123", + "authorize_code": "abcd_code", + "is_on": true, + "brightness": 80, + "color_temp": 20 + } + ] + }, + { + "name": "Office", + "room_id": 1200, + "home_id": 1000, + "groups": [ + { + "name": "Office Lamp", + "group_id": 1110, + "home_id": 1000, + "devices": [ + { + "name": "Lamp Bulb 1", + "is_online": true, + "wifi_connected": false, + "device_id": 1111, + "mesh_device_id": 10002, + "home_id": 1000, + "device_type_id": 137, + "device_type": "LIGHT", + "mac_address": "654321ABCDEF", + "product_id": "product123", + "authorize_code": "abcd_code", + "is_on": true, + "brightness": 90, + "color_temp": 254, + "rgb": [120, 145, 180] + }, + { + "name": "Lamp Bulb 2", + "is_online": false, + "wifi_connected": false, + "device_id": 1112, + "mesh_device_id": 10003, + "home_id": 1000, + "device_type_id": 137, + "device_type": "LIGHT", + "mac_address": "FEDCBA654321", + "product_id": "product123", + "authorize_code": "abcd_code" + } + ] + } + ], + "devices": [] + } + ], + "global_devices": [] +} diff --git a/tests/components/cync/snapshots/test_light.ambr b/tests/components/cync/snapshots/test_light.ambr new file mode 100644 index 00000000000000..fbe56bb1c75feb --- /dev/null +++ b/tests/components/cync/snapshots/test_light.ambr @@ -0,0 +1,233 @@ +# serializer version: 1 +# name: test_entities[light.bedroom_lamp-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 7000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 142, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.bedroom_lamp', + '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': None, + 'platform': 'cync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': '1000-1101', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[light.bedroom_lamp-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 205, + 'color_mode': , + 'color_temp': 333, + 'color_temp_kelvin': 2999, + 'friendly_name': 'Bedroom Lamp', + 'hs_color': tuple( + 27.827, + 56.922, + ), + 'max_color_temp_kelvin': 7000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 142, + 'rgb_color': tuple( + 255, + 177, + 110, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.496, + 0.383, + ), + }), + 'context': , + 'entity_id': 'light.bedroom_lamp', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[light.lamp_bulb_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 7000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 142, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.lamp_bulb_1', + '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': None, + 'platform': 'cync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': '1000-1111', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[light.lamp_bulb_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 230, + 'color_mode': , + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Lamp Bulb 1', + 'hs_color': tuple( + 215.0, + 33.333, + ), + 'max_color_temp_kelvin': 7000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 142, + 'rgb_color': tuple( + 120, + 145, + 180, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.248, + 0.27, + ), + }), + 'context': , + 'entity_id': 'light.lamp_bulb_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[light.lamp_bulb_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 7000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 142, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.lamp_bulb_2', + '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': None, + 'platform': 'cync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': '1000-1112', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[light.lamp_bulb_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Lamp Bulb 2', + 'max_color_temp_kelvin': 7000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 142, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.lamp_bulb_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/cync/test_config_flow.py b/tests/components/cync/test_config_flow.py new file mode 100644 index 00000000000000..28f0aee09daccf --- /dev/null +++ b/tests/components/cync/test_config_flow.py @@ -0,0 +1,260 @@ +"""Test the Cync config flow.""" + +from unittest.mock import ANY, AsyncMock, MagicMock + +from pycync.exceptions import AuthFailedError, CyncError, TwoFactorRequiredError +import pytest + +from homeassistant.components.cync.const import ( + CONF_AUTHORIZE_STRING, + CONF_EXPIRES_AT, + CONF_REFRESH_TOKEN, + CONF_TWO_FACTOR_CODE, + CONF_USER_ID, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import MOCKED_EMAIL, MOCKED_USER + +from tests.common import MockConfigEntry + + +async def test_form_auth_success( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test that an auth flow without two factor succeeds.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: MOCKED_EMAIL, + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCKED_EMAIL + assert result["data"] == { + CONF_USER_ID: MOCKED_USER.user_id, + CONF_AUTHORIZE_STRING: "test_authorize_string", + CONF_EXPIRES_AT: ANY, + CONF_ACCESS_TOKEN: "test_token", + CONF_REFRESH_TOKEN: "test_refresh_token", + } + assert result["result"].unique_id == str(MOCKED_USER.user_id) + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_two_factor_success( + hass: HomeAssistant, mock_setup_entry: AsyncMock, auth_client: MagicMock +) -> None: + """Test we handle a request for a two factor code.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + auth_client.login.side_effect = TwoFactorRequiredError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: MOCKED_EMAIL, + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "two_factor" + + # Enter two factor code + auth_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TWO_FACTOR_CODE: "123456", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCKED_EMAIL + assert result["data"] == { + CONF_USER_ID: MOCKED_USER.user_id, + CONF_AUTHORIZE_STRING: "test_authorize_string", + CONF_EXPIRES_AT: ANY, + CONF_ACCESS_TOKEN: "test_token", + CONF_REFRESH_TOKEN: "test_refresh_token", + } + assert result["result"].unique_id == str(MOCKED_USER.user_id) + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_unique_id_already_exists( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that setting up a config with a unique ID that already exists fails.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: MOCKED_EMAIL, + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("error_type", "error_string"), + [ + (AuthFailedError, "invalid_auth"), + (CyncError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_two_factor_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + auth_client: MagicMock, + error_type: Exception, + error_string: str, +) -> None: + """Test we handle a request for a two factor code with errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + auth_client.login.side_effect = TwoFactorRequiredError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: MOCKED_EMAIL, + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "two_factor" + + # Enter two factor code + auth_client.login.side_effect = error_type + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TWO_FACTOR_CODE: "123456", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_string} + assert result["step_id"] == "user" + + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + auth_client.login.side_effect = TwoFactorRequiredError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: MOCKED_EMAIL, + CONF_PASSWORD: "test-password", + }, + ) + + # Enter two factor code + auth_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TWO_FACTOR_CODE: "567890", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCKED_EMAIL + assert result["data"] == { + CONF_USER_ID: MOCKED_USER.user_id, + CONF_AUTHORIZE_STRING: "test_authorize_string", + CONF_EXPIRES_AT: ANY, + CONF_ACCESS_TOKEN: "test_token", + CONF_REFRESH_TOKEN: "test_refresh_token", + } + assert result["result"].unique_id == str(MOCKED_USER.user_id) + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("error_type", "error_string"), + [ + (AuthFailedError, "invalid_auth"), + (CyncError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + auth_client: MagicMock, + error_type: Exception, + error_string: str, +) -> None: + """Test we handle errors in the user step of the setup.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + auth_client.login.side_effect = error_type + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: MOCKED_EMAIL, + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_string} + assert result["step_id"] == "user" + + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + auth_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: MOCKED_EMAIL, + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCKED_EMAIL + assert result["data"] == { + CONF_USER_ID: MOCKED_USER.user_id, + CONF_AUTHORIZE_STRING: "test_authorize_string", + CONF_EXPIRES_AT: ANY, + CONF_ACCESS_TOKEN: "test_token", + CONF_REFRESH_TOKEN: "test_refresh_token", + } + assert result["result"].unique_id == str(MOCKED_USER.user_id) + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/cync/test_light.py b/tests/components/cync/test_light.py new file mode 100644 index 00000000000000..b5563949f45abc --- /dev/null +++ b/tests/components/cync/test_light.py @@ -0,0 +1,23 @@ +"""Tests for the Cync integration light platform.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test that light attributes are properly set on setup.""" + + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index 296e067ae6b302..da81f2bff8838d 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -26,7 +26,13 @@ ApplicationType, FirmwareInfo, ) -from homeassistant.config_entries import ConfigEntry, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + SOURCE_IGNORE, + SOURCE_USER, + ConfigEntry, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType from homeassistant.exceptions import HomeAssistantError @@ -1100,3 +1106,59 @@ async def test_config_flow_thread_migrate_handler(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["progress_action"] == "install_firmware" assert result["step_id"] == "install_thread_firmware" + + +@pytest.mark.parametrize( + ("zha_source", "otbr_source", "expected_menu"), + [ + ( + SOURCE_USER, + SOURCE_USER, + ["pick_firmware_zigbee_migrate", "pick_firmware_thread_migrate"], + ), + ( + SOURCE_IGNORE, + SOURCE_USER, + ["pick_firmware_zigbee", "pick_firmware_thread_migrate"], + ), + ( + SOURCE_USER, + SOURCE_IGNORE, + ["pick_firmware_zigbee_migrate", "pick_firmware_thread"], + ), + ( + SOURCE_IGNORE, + SOURCE_IGNORE, + ["pick_firmware_zigbee", "pick_firmware_thread"], + ), + ], +) +async def test_config_flow_pick_firmware_with_ignored_entries( + hass: HomeAssistant, zha_source: str, otbr_source: str, expected_menu: str +) -> None: + """Test that ignored entries are properly excluded from migration menu options.""" + zha_entry = MockConfigEntry( + domain="zha", + data={"device": {"path": "/dev/ttyUSB1"}}, + title="ZHA", + source=zha_source, + ) + zha_entry.add_to_hass(hass) + + otbr_entry = MockConfigEntry( + domain="otbr", + data={"url": "http://192.168.1.100:8081"}, + title="OTBR", + source=otbr_source, + ) + otbr_entry.add_to_hass(hass) + + # Set up the flow + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + assert init_result["type"] is FlowResultType.MENU + assert init_result["step_id"] == "pick_firmware" + + assert init_result["menu_options"] == expected_menu diff --git a/tests/components/letpot/snapshots/test_number.ambr b/tests/components/letpot/snapshots/test_number.ambr new file mode 100644 index 00000000000000..50f6cf64312e9d --- /dev/null +++ b/tests/components/letpot/snapshots/test_number.ambr @@ -0,0 +1,116 @@ +# serializer version: 1 +# name: test_all_entities[number.garden_light_brightness-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 8.0, + 'min': 1.0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.garden_light_brightness', + '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 brightness', + 'platform': 'letpot', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light_brightness', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_light_brightness_levels', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.garden_light_brightness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Light brightness', + 'max': 8.0, + 'min': 1.0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.garden_light_brightness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_all_entities[number.garden_plants_age-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 999.0, + 'min': 0.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.garden_plants_age', + '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': 'Plants age', + 'platform': 'letpot', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plant_days', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_plant_days', + 'unit_of_measurement': 'days', + }) +# --- +# name: test_all_entities[number.garden_plants_age-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Plants age', + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1, + 'unit_of_measurement': 'days', + }), + 'context': , + 'entity_id': 'number.garden_plants_age', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- diff --git a/tests/components/letpot/test_number.py b/tests/components/letpot/test_number.py new file mode 100644 index 00000000000000..423ac7c3194091 --- /dev/null +++ b/tests/components/letpot/test_number.py @@ -0,0 +1,99 @@ +"""Test number entities for the LetPot integration.""" + +from unittest.mock import MagicMock, patch + +from letpot.exceptions import LetPotConnectionException, LetPotException +import pytest +from syrupy.assertion import SnapshotAssertion + +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.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_client: MagicMock, + mock_device_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test number entities.""" + with patch("homeassistant.components.letpot.PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_set_number( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, + device_type: str, +) -> None: + """Test number entity set to value.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.garden_light_brightness", + ATTR_VALUE: 6, + }, + blocking=True, + ) + + mock_device_client.set_light_brightness.assert_awaited_once_with( + f"{device_type}ABCD", 750 + ) + + +@pytest.mark.parametrize( + ("exception", "user_error"), + [ + ( + LetPotConnectionException("Connection failed"), + "An error occurred while communicating with the LetPot device: Connection failed", + ), + ( + LetPotException("Random thing failed"), + "An unknown error occurred while communicating with the LetPot device: Random thing failed", + ), + ], +) +async def test_number_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, + exception: Exception, + user_error: str, +) -> None: + """Test number entity exception handling.""" + await setup_integration(hass, mock_config_entry) + + mock_device_client.set_plant_days.side_effect = exception + + with pytest.raises(HomeAssistantError, match=user_error): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.garden_plants_age", + ATTR_VALUE: 7, + }, + blocking=True, + ) diff --git a/tests/components/lg_thinq/conftest.py b/tests/components/lg_thinq/conftest.py index c762d906568c5f..a0626ddb603f16 100644 --- a/tests/components/lg_thinq/conftest.py +++ b/tests/components/lg_thinq/conftest.py @@ -148,4 +148,5 @@ def devices(mock_thinq_api: AsyncMock, device_fixture: str) -> Generator[AsyncMo mock_thinq_api.async_get_device_energy_profile.return_value = ( load_json_object_fixture(f"{device_fixture}/energy_profile.json", DOMAIN) ) + mock_thinq_api.async_get_route.return_value = MagicMock() return mock_thinq_api diff --git a/tests/components/ntfy/test_config_flow.py b/tests/components/ntfy/test_config_flow.py index 9e83858e793848..00118d283361f3 100644 --- a/tests/components/ntfy/test_config_flow.py +++ b/tests/components/ntfy/test_config_flow.py @@ -786,7 +786,7 @@ async def test_topic_reconfigure_flow(hass: HomeAssistant) -> None: CONF_PRIORITY: ["1"], CONF_TAGS: ["owl", "-1"], CONF_TITLE: "", - CONF_MESSAGE: "", + CONF_MESSAGE: "triggered", }, subentry_id="subentry_id", subentry_type="topic", @@ -810,7 +810,6 @@ async def test_topic_reconfigure_flow(hass: HomeAssistant) -> None: CONF_PRIORITY: ["5"], CONF_TAGS: ["octopus", "+1"], CONF_TITLE: "title", - CONF_MESSAGE: "triggered", }, ) @@ -824,7 +823,7 @@ async def test_topic_reconfigure_flow(hass: HomeAssistant) -> None: CONF_PRIORITY: ["5"], CONF_TAGS: ["octopus", "+1"], CONF_TITLE: "title", - CONF_MESSAGE: "triggered", + CONF_MESSAGE: None, }, subentry_id="subentry_id", subentry_type="topic", diff --git a/tests/components/plugwise/snapshots/test_select.ambr b/tests/components/plugwise/snapshots/test_select.ambr new file mode 100644 index 00000000000000..c83e56a3446048 --- /dev/null +++ b/tests/components/plugwise/snapshots/test_select.ambr @@ -0,0 +1,509 @@ +# serializer version: 1 +# name: test_adam_2_select_entities[platforms0-True-m_adam_cooling][select.adam_gateway_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'away', + 'full', + 'vacation', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.adam_gateway_mode', + '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': 'Gateway mode', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gateway_mode', + 'unique_id': 'da224107914542988a88561b4452b0f6-select_gateway_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_2_select_entities[platforms0-True-m_adam_cooling][select.adam_gateway_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Adam Gateway mode', + 'options': list([ + 'away', + 'full', + 'vacation', + ]), + }), + 'context': , + 'entity_id': 'select.adam_gateway_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'full', + }) +# --- +# name: test_adam_2_select_entities[platforms0-True-m_adam_cooling][select.adam_regulation_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'bleeding_hot', + 'bleeding_cold', + 'off', + 'heating', + 'cooling', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.adam_regulation_mode', + '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': 'Regulation mode', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'regulation_mode', + 'unique_id': 'da224107914542988a88561b4452b0f6-select_regulation_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_2_select_entities[platforms0-True-m_adam_cooling][select.adam_regulation_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Adam Regulation mode', + 'options': list([ + 'bleeding_hot', + 'bleeding_cold', + 'off', + 'heating', + 'cooling', + ]), + }), + 'context': , + 'entity_id': 'select.adam_regulation_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cooling', + }) +# --- +# name: test_adam_2_select_entities[platforms0-True-m_adam_cooling][select.bathroom_thermostat_schedule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Badkamer', + 'Test', + 'Vakantie', + 'Weekschema', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.bathroom_thermostat_schedule', + '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': 'Thermostat schedule', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'select_schedule', + 'unique_id': 'f871b8c4d63549319221e294e4f88074-select_schedule', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_2_select_entities[platforms0-True-m_adam_cooling][select.bathroom_thermostat_schedule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bathroom Thermostat schedule', + 'options': list([ + 'Badkamer', + 'Test', + 'Vakantie', + 'Weekschema', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.bathroom_thermostat_schedule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Badkamer', + }) +# --- +# name: test_adam_2_select_entities[platforms0-True-m_adam_cooling][select.living_room_thermostat_schedule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Badkamer', + 'Test', + 'Vakantie', + 'Weekschema', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.living_room_thermostat_schedule', + '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': 'Thermostat schedule', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'select_schedule', + 'unique_id': 'f2bf9048bef64cc5b6d5110154e33c81-select_schedule', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_2_select_entities[platforms0-True-m_adam_cooling][select.living_room_thermostat_schedule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Living room Thermostat schedule', + 'options': list([ + 'Badkamer', + 'Test', + 'Vakantie', + 'Weekschema', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.living_room_thermostat_schedule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_adam_select_entities[platforms0][select.badkamer_thermostat_schedule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.badkamer_thermostat_schedule', + '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': 'Thermostat schedule', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'select_schedule', + 'unique_id': '08963fec7c53423ca5680aa4cb502c63-select_schedule', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_select_entities[platforms0][select.badkamer_thermostat_schedule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Badkamer Thermostat schedule', + 'options': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.badkamer_thermostat_schedule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Badkamer Schema', + }) +# --- +# name: test_adam_select_entities[platforms0][select.bios_thermostat_schedule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.bios_thermostat_schedule', + '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': 'Thermostat schedule', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'select_schedule', + 'unique_id': '12493538af164a409c6a1c79e38afe1c-select_schedule', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_select_entities[platforms0][select.bios_thermostat_schedule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bios Thermostat schedule', + 'options': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.bios_thermostat_schedule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_adam_select_entities[platforms0][select.jessie_thermostat_schedule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.jessie_thermostat_schedule', + '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': 'Thermostat schedule', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'select_schedule', + 'unique_id': '82fa13f017d240daa0d0ea1775420f24-select_schedule', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_select_entities[platforms0][select.jessie_thermostat_schedule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Jessie Thermostat schedule', + 'options': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.jessie_thermostat_schedule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'CV Jessie', + }) +# --- +# name: test_adam_select_entities[platforms0][select.woonkamer_thermostat_schedule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.woonkamer_thermostat_schedule', + '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': 'Thermostat schedule', + 'platform': 'plugwise', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'select_schedule', + 'unique_id': 'c50f167537524366a5af7aa3942feb1e-select_schedule', + 'unit_of_measurement': None, + }) +# --- +# name: test_adam_select_entities[platforms0][select.woonkamer_thermostat_schedule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Woonkamer Thermostat schedule', + 'options': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.woonkamer_thermostat_schedule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'GF7 Woonkamer', + }) +# --- diff --git a/tests/components/plugwise/test_select.py b/tests/components/plugwise/test_select.py index f6c4205b756fdf..91ef44049fd1ec 100644 --- a/tests/components/plugwise/test_select.py +++ b/tests/components/plugwise/test_select.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.select import ( ATTR_OPTION, @@ -12,18 +13,22 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform +@pytest.mark.parametrize("platforms", [(SELECT_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_adam_select_entities( - hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry + hass: HomeAssistant, + mock_smile_adam: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, ) -> None: - """Test a thermostat Select.""" - - state = hass.states.get("select.woonkamer_thermostat_schedule") - assert state - assert state.state == "GF7 Woonkamer" + """Test Adam select snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) async def test_adam_change_select_entity( @@ -50,6 +55,21 @@ async def test_adam_change_select_entity( ) +@pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) +@pytest.mark.parametrize("platforms", [(SELECT_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_adam_2_select_entities( + hass: HomeAssistant, + mock_smile_adam_heat_cool: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: MockConfigEntry, +) -> None: + """Test Adam with cooling select snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + @pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True) @pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_adam_select_regulation_mode( @@ -57,17 +77,10 @@ async def test_adam_select_regulation_mode( mock_smile_adam_heat_cool: MagicMock, init_integration: MockConfigEntry, ) -> None: - """Test a regulation_mode select. + """Test changing the regulation_mode select. Also tests a change in climate _previous mode. """ - - state = hass.states.get("select.adam_gateway_mode") - assert state - assert state.state == "full" - state = hass.states.get("select.adam_regulation_mode") - assert state - assert state.state == "cooling" await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, @@ -97,10 +110,10 @@ async def test_legacy_anna_select_entities( @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) @pytest.mark.parametrize("cooling_present", [True], indirect=True) -async def test_adam_select_unavailable_regulation_mode( +async def test_anna_select_unavailable_schedule_mode( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: - """Test a regulation_mode non-available preset.""" + """Fail-test an Anna thermostat_schedule select option.""" with pytest.raises(ServiceValidationError, match="valid options"): await hass.services.async_call( @@ -108,7 +121,7 @@ async def test_adam_select_unavailable_regulation_mode( SERVICE_SELECT_OPTION, { ATTR_ENTITY_ID: "select.anna_thermostat_schedule", - ATTR_OPTION: "freezing", + ATTR_OPTION: "Winter", }, blocking=True, ) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 2911c851dae1a3..f40bfa8398521d 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -252,6 +252,7 @@ def reolink_chime(reolink_host: MagicMock) -> None: } TEST_CHIME.remove = AsyncMock() TEST_CHIME.set_option = AsyncMock() + TEST_CHIME.update_enums() reolink_host.chime_list = [TEST_CHIME] reolink_host.chime.return_value = TEST_CHIME diff --git a/tests/components/reolink/test_number.py b/tests/components/reolink/test_number.py index 853edeefa5a1f9..3e49a5dd4a7841 100644 --- a/tests/components/reolink/test_number.py +++ b/tests/components/reolink/test_number.py @@ -147,13 +147,16 @@ async def test_host_number( ) +@pytest.mark.parametrize("channel", [0, None]) async def test_chime_number( hass: HomeAssistant, config_entry: MockConfigEntry, reolink_host: MagicMock, reolink_chime: Chime, + channel: int | None, ) -> None: """Test number entity of a chime with chime volume.""" + reolink_chime.channel = channel reolink_chime.volume = 3 with patch("homeassistant.components.reolink.PLATFORMS", [Platform.NUMBER]): diff --git a/tests/components/reolink/test_select.py b/tests/components/reolink/test_select.py index 5dcce74751860b..e74bcf8fc753ae 100644 --- a/tests/components/reolink/test_select.py +++ b/tests/components/reolink/test_select.py @@ -149,6 +149,7 @@ async def test_host_scene_select( assert hass.states.get(entity_id).state == STATE_UNKNOWN +@pytest.mark.parametrize("channel", [0, None]) async def test_chime_select( hass: HomeAssistant, freezer: FrozenDateTimeFactory, @@ -156,8 +157,11 @@ async def test_chime_select( reolink_host: MagicMock, reolink_chime: Chime, entity_registry: er.EntityRegistry, + channel: int | None, ) -> None: """Test chime select entity.""" + reolink_chime.channel = channel + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SELECT]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -197,6 +201,7 @@ async def test_chime_select( # Test unavailable reolink_chime.event_info = {} + reolink_chime.update_enums() freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() diff --git a/tests/components/reolink/test_switch.py b/tests/components/reolink/test_switch.py index c8a38f19d5ca8f..97dfc622aed043 100644 --- a/tests/components/reolink/test_switch.py +++ b/tests/components/reolink/test_switch.py @@ -164,14 +164,18 @@ async def test_host_switch( ) +@pytest.mark.parametrize("channel", [0, None]) async def test_chime_switch( hass: HomeAssistant, config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, reolink_host: MagicMock, reolink_chime: Chime, + channel: int | None, ) -> None: """Test host switch entity.""" + reolink_chime.channel = channel + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/route_b_smart_meter/__init__.py b/tests/components/route_b_smart_meter/__init__.py new file mode 100644 index 00000000000000..7b998b1f4bd922 --- /dev/null +++ b/tests/components/route_b_smart_meter/__init__.py @@ -0,0 +1 @@ +"""Tests for the Smart Meter B-route integration.""" diff --git a/tests/components/route_b_smart_meter/conftest.py b/tests/components/route_b_smart_meter/conftest.py new file mode 100644 index 00000000000000..f0a84c252a0c57 --- /dev/null +++ b/tests/components/route_b_smart_meter/conftest.py @@ -0,0 +1,72 @@ +"""Common fixtures for the Smart Meter B-route tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from homeassistant.components.route_b_smart_meter.const import DOMAIN +from homeassistant.const import CONF_DEVICE, CONF_ID, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.route_b_smart_meter.async_setup_entry", + return_value=True, + ) as mock: + yield mock + + +@pytest.fixture +def mock_momonga(exception=None) -> Generator[Mock]: + """Mock for Momonga class.""" + + with ( + patch( + "homeassistant.components.route_b_smart_meter.coordinator.Momonga", + ) as mock_momonga, + patch( + "homeassistant.components.route_b_smart_meter.config_flow.Momonga", + new=mock_momonga, + ), + ): + client = mock_momonga.return_value + client.__enter__.return_value = client + client.__exit__.return_value = None + client.get_instantaneous_current.return_value = { + "r phase current": 1, + "t phase current": 2, + } + client.get_instantaneous_power.return_value = 3 + client.get_measured_cumulative_energy.return_value = 4 + yield mock_momonga + + +@pytest.fixture +def user_input() -> dict[str, str]: + """Return test user input data.""" + return { + CONF_DEVICE: "/dev/ttyUSB42", + CONF_ID: "01234567890123456789012345F789", + CONF_PASSWORD: "B_ROUTE_PASSWORD", + } + + +@pytest.fixture +def mock_config_entry( + hass: HomeAssistant, user_input: dict[str, str] +) -> MockConfigEntry: + """Create a mock config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=user_input, + entry_id="01234567890123456789012345F789", + unique_id="123456", + ) + entry.add_to_hass(hass) + return entry diff --git a/tests/components/route_b_smart_meter/snapshots/test_sensor.ambr b/tests/components/route_b_smart_meter/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..552e46aa68763f --- /dev/null +++ b/tests/components/route_b_smart_meter/snapshots/test_sensor.ambr @@ -0,0 +1,225 @@ +# serializer version: 1 +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_r_phase-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.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_r_phase', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Instantaneous current R phase', + 'platform': 'route_b_smart_meter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'instantaneous_current_r_phase', + 'unique_id': '01234567890123456789012345F789_instantaneous_current_r_phase', + 'unit_of_measurement': , + }) +# --- +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_r_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Route B Smart Meter 01234567890123456789012345F789 Instantaneous current R phase', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_r_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_t_phase-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.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_t_phase', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Instantaneous current T phase', + 'platform': 'route_b_smart_meter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'instantaneous_current_t_phase', + 'unique_id': '01234567890123456789012345F789_instantaneous_current_t_phase', + 'unit_of_measurement': , + }) +# --- +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_t_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Route B Smart Meter 01234567890123456789012345F789 Instantaneous current T phase', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_t_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_power-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.route_b_smart_meter_01234567890123456789012345f789_instantaneous_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Instantaneous power', + 'platform': 'route_b_smart_meter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'instantaneous_power', + 'unique_id': '01234567890123456789012345F789_instantaneous_power', + 'unit_of_measurement': , + }) +# --- +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Route B Smart Meter 01234567890123456789012345F789 Instantaneous power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_total_consumption-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.route_b_smart_meter_01234567890123456789012345f789_total_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total consumption', + 'platform': 'route_b_smart_meter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_consumption', + 'unique_id': '01234567890123456789012345F789_total_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_total_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Route B Smart Meter 01234567890123456789012345F789 Total consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.route_b_smart_meter_01234567890123456789012345f789_total_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4', + }) +# --- diff --git a/tests/components/route_b_smart_meter/test_config_flow.py b/tests/components/route_b_smart_meter/test_config_flow.py new file mode 100644 index 00000000000000..d7dc84a99992ea --- /dev/null +++ b/tests/components/route_b_smart_meter/test_config_flow.py @@ -0,0 +1,111 @@ +"""Test the Smart Meter B-route config flow.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, patch + +from momonga import MomongaSkJoinFailure, MomongaSkScanFailure +import pytest +from serial.tools.list_ports_linux import SysFS + +from homeassistant.components.route_b_smart_meter.const import DOMAIN, ENTRY_TITLE +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_DEVICE, CONF_ID, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +@pytest.fixture +def mock_comports() -> Generator[AsyncMock]: + """Override comports.""" + device = SysFS("/dev/ttyUSB42") + device.vid = 0x1234 + device.pid = 0x5678 + device.serial_number = "123456" + device.manufacturer = "Test" + device.description = "Test Device" + + with patch( + "homeassistant.components.route_b_smart_meter.config_flow.comports", + return_value=[SysFS("/dev/ttyUSB41"), device], + ) as mock: + yield mock + + +async def test_step_user_form( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_comports: AsyncMock, + mock_momonga: Mock, + user_input: dict[str, str], +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ENTRY_TITLE + assert result["data"] == user_input + assert result["result"].unique_id == user_input[CONF_ID] + mock_setup_entry.assert_called_once() + mock_comports.assert_called() + mock_momonga.assert_called_once_with( + dev=user_input[CONF_DEVICE], + rbid=user_input[CONF_ID], + pwd=user_input[CONF_PASSWORD], + ) + + +@pytest.mark.parametrize( + ("error", "message"), + [ + (MomongaSkJoinFailure, "invalid_auth"), + (MomongaSkScanFailure, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_step_user_form_errors( + hass: HomeAssistant, + error: Exception, + message: str, + mock_setup_entry: AsyncMock, + mock_comports: AsyncMock, + mock_momonga: AsyncMock, + user_input: dict[str, str], +) -> None: + """Test we handle error.""" + result_init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + mock_momonga.side_effect = error + result_configure = await hass.config_entries.flow.async_configure( + result_init["flow_id"], + user_input, + ) + + assert result_configure["type"] is FlowResultType.FORM + assert result_configure["errors"] == {"base": message} + await hass.async_block_till_done() + mock_comports.assert_called() + mock_momonga.assert_called_once_with( + dev=user_input[CONF_DEVICE], + rbid=user_input[CONF_ID], + pwd=user_input[CONF_PASSWORD], + ) + + mock_momonga.side_effect = None + result = await hass.config_entries.flow.async_configure( + result_configure["flow_id"], + user_input, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ENTRY_TITLE + assert result["data"] == user_input diff --git a/tests/components/route_b_smart_meter/test_init.py b/tests/components/route_b_smart_meter/test_init.py new file mode 100644 index 00000000000000..644fda84886171 --- /dev/null +++ b/tests/components/route_b_smart_meter/test_init.py @@ -0,0 +1,19 @@ +"""Tests for the Smart Meter B Route integration init.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_async_setup_entry_success( + hass: HomeAssistant, mock_momonga, mock_config_entry: MockConfigEntry +) -> None: + """Test successful setup of entry.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/route_b_smart_meter/test_sensor.py b/tests/components/route_b_smart_meter/test_sensor.py new file mode 100644 index 00000000000000..63d9cac044998d --- /dev/null +++ b/tests/components/route_b_smart_meter/test_sensor.py @@ -0,0 +1,55 @@ +"""Tests for the Smart Meter B-Route sensor.""" + +from unittest.mock import Mock + +from freezegun.api import FrozenDateTimeFactory +from momonga import MomongaError +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.route_b_smart_meter.const import DEFAULT_SCAN_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_route_b_smart_meter_sensor_update( + hass: HomeAssistant, + mock_momonga: Mock, + freezer: FrozenDateTimeFactory, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the BRouteUpdateCoordinator successful behavior.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_route_b_smart_meter_sensor_no_update( + hass: HomeAssistant, + mock_momonga: Mock, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the BRouteUpdateCoordinator when failing.""" + + entity_id = "sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_r_phase" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity = hass.states.get(entity_id) + assert entity.state == "1" + + mock_momonga.return_value.get_instantaneous_current.side_effect = MomongaError + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + entity = hass.states.get(entity_id) + assert entity.state is STATE_UNAVAILABLE diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index 9f7871827fe23d..e751fafca24249 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -415,6 +415,7 @@ async def test_play_media_lib_track_add( _share_link: str = "spotify:playlist:abcdefghij0123456789XY" +_share_link_title: str = "playlist title" async def test_play_media_share_link_add( @@ -432,6 +433,7 @@ async def test_play_media_share_link_add( ATTR_MEDIA_CONTENT_TYPE: "playlist", ATTR_MEDIA_CONTENT_ID: _share_link, ATTR_MEDIA_ENQUEUE: MediaPlayerEnqueue.ADD, + ATTR_MEDIA_EXTRA: {"title": _share_link_title}, }, blocking=True, ) @@ -443,6 +445,10 @@ async def test_play_media_share_link_add( soco_sharelink.add_share_link_to_queue.call_args_list[0].kwargs["timeout"] == LONG_SERVICE_TIMEOUT ) + assert ( + soco_sharelink.add_share_link_to_queue.call_args_list[0].kwargs["dc_title"] + == _share_link_title + ) async def test_play_media_share_link_next( @@ -474,6 +480,10 @@ async def test_play_media_share_link_next( assert ( soco_sharelink.add_share_link_to_queue.call_args_list[0].kwargs["position"] == 1 ) + assert ( + "dc_title" + not in soco_sharelink.add_share_link_to_queue.call_args_list[0].kwargs + ) async def test_play_media_share_link_play( diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 70419a4b503ea0..ff4c7443fa13fc 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -5,7 +5,14 @@ from ipaddress import ip_address import json from typing import Any -from unittest.mock import AsyncMock, MagicMock, PropertyMock, create_autospec, patch +from unittest.mock import ( + AsyncMock, + MagicMock, + PropertyMock, + call, + create_autospec, + patch, +) import uuid import pytest @@ -585,14 +592,21 @@ async def test_migration_strategy_recommended( assert result_confirm["step_id"] == "choose_migration_strategy" - with patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", - ) as mock_restore_backup: + with ( + patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.restore_backup", + ) as mock_restore_backup, + patch( + "homeassistant.config_entries.ConfigEntries.async_unload", + return_value=True, + ) as mock_async_unload, + ): result_recommended = await hass.config_entries.flow.async_configure( result_confirm["flow_id"], user_input={"next_step_id": config_flow.MIGRATION_STRATEGY_RECOMMENDED}, ) + assert mock_async_unload.mock_calls == [call(entry.entry_id)] assert result_recommended["type"] is FlowResultType.ABORT assert result_recommended["reason"] == "reconfigure_successful" mock_restore_backup.assert_called_once() @@ -2364,3 +2378,52 @@ async def test_formation_strategy_restore_manual_backup_overwrite_ieee_ezsp_writ assert mock_restore_backup.call_count == 1 assert mock_restore_backup.mock_calls[0].kwargs["overwrite_ieee"] is True + + +@patch(f"bellows.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) +async def test_migrate_setup_options_with_ignored_discovery( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test that ignored discovery info is migrated to options.""" + + # Ignored ZHA + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="AAAA:AAAA_1234_test_zigbee radio", + data={ + CONF_DEVICE: { + CONF_DEVICE_PATH: "/dev/ttyUSB1", + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, + } + }, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + + # Set up one discovery entry + discovery_info = UsbServiceInfo( + device="/dev/ttyZIGBEE", + pid="BBBB", + vid="BBBB", + serial_number="5678", + description="zigbee radio", + manufacturer="test manufacturer", + ) + discovery_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + + # Progress the discovery + confirm_result = await hass.config_entries.flow.async_configure( + discovery_result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + + # We only show "setup" options, not "migrate" + assert confirm_result["step_id"] == "choose_setup_strategy" + assert confirm_result["menu_options"] == [ + "setup_strategy_recommended", + "setup_strategy_advanced", + ]