diff --git a/.core_files.yaml b/.core_files.yaml index 62a787df0fd96..ea08fd4a53cdb 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -36,6 +36,7 @@ base_platforms: &base_platforms - homeassistant/components/image_processing/** - homeassistant/components/infrared/** - homeassistant/components/lawn_mower/** + - homeassistant/components/radio_frequency/** - homeassistant/components/light/** - homeassistant/components/lock/** - homeassistant/components/media_player/** diff --git a/CODEOWNERS b/CODEOWNERS index d79b2a229ec0c..f6ffb7ad5cbb5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -758,6 +758,8 @@ CLAUDE.md @home-assistant/core /tests/components/homewizard/ @DCSBL /homeassistant/components/honeywell/ @rdfurman @mkmer /tests/components/honeywell/ @rdfurman @mkmer +/homeassistant/components/honeywell_string_lights/ @balloob +/tests/components/honeywell_string_lights/ @balloob /homeassistant/components/hr_energy_qube/ @MattieGit /tests/components/hr_energy_qube/ @MattieGit /homeassistant/components/html5/ @alexyao2015 @tr4nt0r @@ -1415,6 +1417,8 @@ CLAUDE.md @home-assistant/core /tests/components/radarr/ @tkdrob /homeassistant/components/radio_browser/ @frenck /tests/components/radio_browser/ @frenck +/homeassistant/components/radio_frequency/ @home-assistant/core +/tests/components/radio_frequency/ @home-assistant/core /homeassistant/components/radiotherm/ @vinnyfuria /tests/components/radiotherm/ @vinnyfuria /homeassistant/components/rainbird/ @konikvranik @allenporter diff --git a/homeassistant/brands/honeywell.json b/homeassistant/brands/honeywell.json index 37cd6d8ce732e..001db20de07af 100644 --- a/homeassistant/brands/honeywell.json +++ b/homeassistant/brands/honeywell.json @@ -1,5 +1,5 @@ { "domain": "honeywell", "name": "Honeywell", - "integrations": ["lyric", "evohome", "honeywell"] + "integrations": ["lyric", "evohome", "honeywell", "honeywell_string_lights"] } diff --git a/homeassistant/components/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py index 0520cb8039eb1..1e5b641a357f6 100644 --- a/homeassistant/components/bsblan/__init__.py +++ b/homeassistant/components/bsblan/__init__.py @@ -13,6 +13,7 @@ Info, StaticState, ) +from yarl import URL from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -28,11 +29,16 @@ ConfigEntryError, ConfigEntryNotReady, ) -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceInfo, + format_mac, +) from homeassistant.helpers.typing import ConfigType -from .const import CONF_PASSKEY, DOMAIN, LOGGER +from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator from .services import async_setup_services @@ -52,7 +58,35 @@ class BSBLanData: client: BSBLAN device: Device info: Info - static: StaticState | None + static: dict[int, StaticState | None] + available_circuits: list[int] + + +def get_bsblan_device_info( + device: Device, info: Info, host: str, port: int +) -> DeviceInfo: + """Build DeviceInfo for the main BSB-LAN controller device.""" + return DeviceInfo( + identifiers={(DOMAIN, device.MAC)}, + connections={(CONNECTION_NETWORK_MAC, format_mac(device.MAC))}, + name=device.name, + manufacturer="BSBLAN Inc.", + model=( + info.device_identification.value + if info.device_identification and info.device_identification.value + else None + ), + model_id=( + f"{info.controller_family.value}_{info.controller_variant.value}" + if info.controller_family + and info.controller_variant + and info.controller_family.value + and info.controller_variant.value + else None + ), + sw_version=device.version, + configuration_url=str(URL.build(scheme="http", host=host, port=port)), + ) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -75,13 +109,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo # create BSBLAN client session = async_get_clientsession(hass) - bsblan = BSBLAN(config, session) + bsblan = BSBLAN(config=config, session=session) try: # Initialize the client first - this sets up internal caches and validates # the connection by fetching firmware version await bsblan.initialize() + # Read available heating circuits from config entry data + # (populated by config flow or migration) + circuits: list[int] = entry.data[CONF_HEATING_CIRCUITS] + # Fetch required device metadata in parallel for faster startup device, info = await asyncio.gather( bsblan.device(), @@ -110,18 +148,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo translation_key="setup_general_error", ) from err - try: - static = await bsblan.static_values() - except (BSBLANError, TimeoutError) as err: - LOGGER.debug( - "Static values not available for %s: %s", - entry.data[CONF_HOST], - err, - ) - static = None + # Fetch static values per configured circuit. + # BSB-LAN is a serial bus — it processes one parameter at a time, + # so concurrent requests offer no speed benefit over sequential. + # Static values are optional — some devices may not support them. + static_per_circuit: dict[int, StaticState | None] = {} + for circuit in circuits: + try: + static_per_circuit[circuit] = await bsblan.static_values(circuit=circuit) + except (BSBLANError, TimeoutError) as err: + LOGGER.debug( + "Static values not available for %s circuit %d: %s", + entry.data[CONF_HOST], + circuit, + err, + ) + static_per_circuit[circuit] = None # Create coordinators with the already-initialized client - fast_coordinator = BSBLanFastCoordinator(hass, entry, bsblan) + fast_coordinator = BSBLanFastCoordinator(hass, entry, bsblan, circuits) slow_coordinator = BSBLanSlowCoordinator(hass, entry, bsblan) # Perform first refresh of fast coordinator (required for entities) @@ -137,7 +182,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo slow_coordinator=slow_coordinator, device=device, info=info, - static=static, + static=static_per_circuit, + available_circuits=circuits, + ) + + # Register main device before forwarding platforms, so sub-devices + # (heating circuits, water heater) can reference it via via_device + device_registry = dr.async_get(hass) + port = entry.data.get(CONF_PORT, DEFAULT_PORT) + main_device_info = get_bsblan_device_info(device, info, entry.data[CONF_HOST], port) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers=main_device_info["identifiers"], + connections=main_device_info["connections"], + name=main_device_info["name"], + manufacturer=main_device_info["manufacturer"], + model=main_device_info.get("model"), + model_id=main_device_info.get("model_id"), + sw_version=main_device_info.get("sw_version"), + configuration_url=main_device_info.get("configuration_url"), ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -148,3 +211,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bool: """Unload BSBLAN config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bool: + """Migrate old config entries to the latest schema.""" + LOGGER.debug( + "Migrating BSB-LAN entry from version %s.%s", + entry.version, + entry.minor_version, + ) + + if entry.version > 1: + # Downgraded from a future version; cannot migrate. + return False + + # 1.1 -> 1.2: Add CONF_HEATING_CIRCUITS. Attempt to discover available + # heating circuits from the device; fall back to [1] (pre-multi-circuit + # default) if the device is unreachable or the endpoint is unsupported. + if entry.version == 1 and entry.minor_version < 2: + circuits: list[int] = [1] + config = BSBLANConfig( + host=entry.data[CONF_HOST], + passkey=entry.data[CONF_PASSKEY], + port=entry.data[CONF_PORT], + username=entry.data.get(CONF_USERNAME), + password=entry.data.get(CONF_PASSWORD), + ) + session = async_get_clientsession(hass) + bsblan = BSBLAN(config=config, session=session) + try: + await bsblan.initialize() + circuits = await bsblan.get_available_circuits() + except (BSBLANError, TimeoutError) as err: + LOGGER.warning( + "Circuit discovery during migration failed for %s (%s); " + "defaulting to single circuit [1]. Use Reconfigure to " + "rediscover additional circuits later", + entry.data[CONF_HOST], + err, + ) + + hass.config_entries.async_update_entry( + entry, + data={**entry.data, CONF_HEATING_CIRCUITS: circuits}, + minor_version=2, + ) + LOGGER.debug( + "Migrated BSB-LAN entry to version %s.%s with circuits %s", + entry.version, + entry.minor_version, + circuits, + ) + + return True diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index 8ae03e0a7a2ee..fc8dbd4ff55b2 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -4,7 +4,7 @@ from typing import Any, Final -from bsblan import BSBLANError, get_hvac_action_category +from bsblan import BSBLANError, State, get_hvac_action_category from homeassistant.components.climate import ( ATTR_HVAC_MODE, @@ -24,7 +24,7 @@ from . import BSBLanConfigEntry, BSBLanData from .const import ATTR_TARGET_TEMPERATURE, DOMAIN -from .entity import BSBLanEntity +from .entity import BSBLanCircuitEntity PARALLEL_UPDATES = 1 @@ -63,10 +63,12 @@ async def async_setup_entry( ) -> None: """Set up BSBLAN device based on a config entry.""" data = entry.runtime_data - async_add_entities([BSBLANClimate(data)]) + async_add_entities( + BSBLANClimate(data, circuit) for circuit in data.available_circuits + ) -class BSBLANClimate(BSBLanEntity, ClimateEntity): +class BSBLANClimate(BSBLanCircuitEntity, ClimateEntity): """Defines a BSBLAN climate device.""" _attr_name = None @@ -84,37 +86,50 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity): def __init__( self, data: BSBLanData, + circuit: int, ) -> None: """Initialize BSBLAN climate device.""" - super().__init__(data.fast_coordinator, data) - self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate" - - # Set temperature range if available, otherwise use Home Assistant defaults - if (static := data.static) is not None: + super().__init__(data.fast_coordinator, data, circuit) + self._circuit = circuit + mac = format_mac(data.device.MAC) + + # Backward compatible unique ID: circuit 1 keeps old format + if circuit == 1: + self._attr_unique_id = f"{mac}-climate" + else: + self._attr_unique_id = f"{mac}-climate-{circuit}" + + # Set temperature range from per-circuit static data + if (static := data.static.get(circuit)) is not None: if (min_temp := static.min_temp) is not None and min_temp.value is not None: self._attr_min_temp = min_temp.value if (max_temp := static.max_temp) is not None and max_temp.value is not None: self._attr_max_temp = max_temp.value self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit + @property + def _circuit_state(self) -> State: + """Return the state for this circuit.""" + return self.coordinator.data.states[self._circuit] + @property def current_temperature(self) -> float | None: """Return the current temperature.""" - if (current_temp := self.coordinator.data.state.current_temperature) is None: + if (current_temp := self._circuit_state.current_temperature) is None: return None return current_temp.value @property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" - if (target_temp := self.coordinator.data.state.target_temperature) is None: + if (target_temp := self._circuit_state.target_temperature) is None: return None return target_temp.value @property def _hvac_mode_value(self) -> int | None: """Return the raw hvac_mode value from the coordinator.""" - if (hvac_mode := self.coordinator.data.state.hvac_mode) is None: + if (hvac_mode := self._circuit_state.hvac_mode) is None: return None return hvac_mode.value @@ -128,9 +143,7 @@ def hvac_mode(self) -> HVACMode | None: @property def hvac_action(self) -> HVACAction | None: """Return the current running hvac action.""" - if ( - action := self.coordinator.data.state.hvac_action - ) is None or action.value is None: + if (action := self._circuit_state.hvac_action) is None or action.value is None: return None category = get_hvac_action_category(action.value) return HVACAction(category.name.lower()) @@ -170,7 +183,7 @@ async def async_set_data(self, **kwargs: Any) -> None: data[ATTR_HVAC_MODE] = 1 try: - await self.coordinator.client.thermostat(**data) + await self.coordinator.client.thermostat(**data, circuit=self._circuit) except BSBLANError as err: raise HomeAssistantError( "An error occurred while updating the BSBLAN device", diff --git a/homeassistant/components/bsblan/config_flow.py b/homeassistant/components/bsblan/config_flow.py index 01024a07e42c9..9ff633b048078 100644 --- a/homeassistant/components/bsblan/config_flow.py +++ b/homeassistant/components/bsblan/config_flow.py @@ -15,19 +15,21 @@ from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from .const import CONF_PASSKEY, DEFAULT_PORT, DOMAIN +from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a BSBLAN config flow.""" VERSION = 1 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize BSBLan flow.""" self.host: str = "" self.port: int = DEFAULT_PORT self.mac: str | None = None + self.circuits: list[int] = [1] self.passkey: str | None = None self.username: str | None = None self.password: str | None = None @@ -77,7 +79,7 @@ async def async_step_zeroconf( # Try to get device info without authentication to minimize discovery popup config = BSBLANConfig(host=self.host, port=self.port) session = async_get_clientsession(self.hass) - bsblan = BSBLAN(config, session) + bsblan = BSBLAN(config=config, session=session) try: device = await bsblan.device() except BSBLANError: @@ -123,6 +125,8 @@ async def async_step_discovery_confirm( ) if not self._auth_required: + # Discover available heating circuits + await self._discover_circuits() return self._async_create_entry() self.passkey = user_input.get(CONF_PASSKEY) @@ -137,6 +141,7 @@ async def _validate_and_create( """Validate device connection and create entry.""" try: await self._get_bsblan_info() + await self._discover_circuits() except BSBLANAuthError: if is_discovery: return self.async_show_form( @@ -230,9 +235,12 @@ async def async_step_reconfigure( # it gets the unique ID from the device info when it validates credentials self._abort_if_unique_id_mismatch() + # Rediscover circuits in case hardware changed + await self._discover_circuits() + return self.async_update_reload_and_abort( existing_entry, - data_updates=user_input, + data_updates={**user_input, CONF_HEATING_CIRCUITS: self.circuits}, reason="reconfigure_successful", ) @@ -316,13 +324,14 @@ def _show_setup_form( def _async_create_entry(self) -> ConfigFlowResult: """Create the config entry.""" return self.async_create_entry( - title=format_mac(self.mac), + title="BSB-LAN", data={ CONF_HOST: self.host, CONF_PORT: self.port, CONF_PASSKEY: self.passkey, CONF_USERNAME: self.username, CONF_PASSWORD: self.password, + CONF_HEATING_CIRCUITS: self.circuits, }, ) @@ -340,7 +349,7 @@ async def _get_bsblan_info( password=self.password, ) session = async_get_clientsession(self.hass) - bsblan = BSBLAN(config, session) + bsblan = BSBLAN(config=config, session=session) device = await bsblan.device() retrieved_mac = device.MAC @@ -362,3 +371,27 @@ async def _get_bsblan_info( CONF_PORT: self.port, } ) + + async def _discover_circuits(self) -> None: + """Discover available heating circuits.""" + config = BSBLANConfig( + host=self.host, + passkey=self.passkey, + port=self.port, + username=self.username, + password=self.password, + ) + session = async_get_clientsession(self.hass) + bsblan = BSBLAN(config=config, session=session) + try: + await bsblan.initialize() + self.circuits = await bsblan.get_available_circuits() + except ( + BSBLANError, + TimeoutError, + ): + LOGGER.debug( + "Circuit discovery not available for %s, defaulting to single circuit", + self.host, + ) + self.circuits = [1] diff --git a/homeassistant/components/bsblan/const.py b/homeassistant/components/bsblan/const.py index 8dfdc180089da..669db12dc9c03 100644 --- a/homeassistant/components/bsblan/const.py +++ b/homeassistant/components/bsblan/const.py @@ -22,5 +22,6 @@ ATTR_OUTSIDE_TEMPERATURE: Final = "outside_temperature" CONF_PASSKEY: Final = "passkey" +CONF_HEATING_CIRCUITS: Final = "heating_circuits" DEFAULT_PORT: Final = 80 diff --git a/homeassistant/components/bsblan/coordinator.py b/homeassistant/components/bsblan/coordinator.py index a2805aa5ff13d..6ba18827fa97e 100644 --- a/homeassistant/components/bsblan/coordinator.py +++ b/homeassistant/components/bsblan/coordinator.py @@ -49,7 +49,7 @@ class BSBLanFastData: """BSBLan fast-polling data.""" - state: State + states: dict[int, State] sensor: Sensor dhw: HotWaterState | None = None @@ -94,6 +94,7 @@ def __init__( hass: HomeAssistant, config_entry: BSBLanConfigEntry, client: BSBLAN, + circuits: list[int], ) -> None: """Initialize the BSB-LAN fast coordinator.""" super().__init__( @@ -103,14 +104,19 @@ def __init__( name=f"{DOMAIN}_fast_{config_entry.data[CONF_HOST]}", update_interval=SCAN_INTERVAL_FAST, ) + self.circuits: list[int] = circuits async def _async_update_data(self) -> BSBLanFastData: """Fetch fast-changing data from the BSB-LAN device.""" + states: dict[int, State] = {} try: - # Client is already initialized in async_setup_entry - # Use include filtering to only fetch parameters we actually use - # This reduces response time significantly (~0.2s per parameter) - state = await self.client.state(include=STATE_INCLUDE) + # Use include filtering to only fetch parameters we actually use. + # BSB-LAN is a serial bus — it processes one parameter at a time, + # so concurrent requests offer no speed benefit over sequential. + for circuit in self.circuits: + states[circuit] = await self.client.state( + include=STATE_INCLUDE, circuit=circuit + ) sensor = await self.client.sensor(include=SENSOR_INCLUDE) except BSBLANAuthError as err: @@ -140,7 +146,7 @@ async def _async_update_data(self) -> BSBLanFastData: ) return BSBLanFastData( - state=state, + states=states, sensor=sensor, dhw=dhw, ) diff --git a/homeassistant/components/bsblan/diagnostics.py b/homeassistant/components/bsblan/diagnostics.py index 324e2fc1497cd..66e5e97172c65 100644 --- a/homeassistant/components/bsblan/diagnostics.py +++ b/homeassistant/components/bsblan/diagnostics.py @@ -20,13 +20,20 @@ async def async_get_config_entry_diagnostics( "info": data.info.model_dump(), "device": data.device.model_dump(), "fast_coordinator_data": { - "state": data.fast_coordinator.data.state.model_dump(), + "states": { + str(circuit): state.model_dump() + for circuit, state in data.fast_coordinator.data.states.items() + }, "sensor": data.fast_coordinator.data.sensor.model_dump(), "dhw": data.fast_coordinator.data.dhw.model_dump() if data.fast_coordinator.data.dhw else None, }, - "static": data.static.model_dump() if data.static is not None else None, + "static": { + str(circuit): static.model_dump() if static is not None else None + for circuit, static in data.static.items() + }, + "available_circuits": data.available_circuits, } # Add DHW config and schedule from slow coordinator if available diff --git a/homeassistant/components/bsblan/entity.py b/homeassistant/components/bsblan/entity.py index 536551fe6d026..d2d1c271d3537 100644 --- a/homeassistant/components/bsblan/entity.py +++ b/homeassistant/components/bsblan/entity.py @@ -2,17 +2,11 @@ from __future__ import annotations -from yarl import URL - from homeassistant.const import CONF_HOST, CONF_PORT -from homeassistant.helpers.device_registry import ( - CONNECTION_NETWORK_MAC, - DeviceInfo, - format_mac, -) +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import BSBLanData +from . import BSBLanData, get_bsblan_device_info from .const import DEFAULT_PORT, DOMAIN from .coordinator import BSBLanCoordinator, BSBLanFastCoordinator, BSBLanSlowCoordinator @@ -27,28 +21,8 @@ def __init__(self, coordinator: _T, data: BSBLanData) -> None: super().__init__(coordinator) host = coordinator.config_entry.data[CONF_HOST] port = coordinator.config_entry.data.get(CONF_PORT, DEFAULT_PORT) - mac = data.device.MAC - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, mac)}, - connections={(CONNECTION_NETWORK_MAC, format_mac(mac))}, - name=data.device.name, - manufacturer="BSBLAN Inc.", - model=( - data.info.device_identification.value - if data.info.device_identification - and data.info.device_identification.value - else None - ), - model_id=( - f"{data.info.controller_family.value}_{data.info.controller_variant.value}" - if data.info.controller_family - and data.info.controller_variant - and data.info.controller_family.value - and data.info.controller_variant.value - else None - ), - sw_version=data.device.version, - configuration_url=str(URL.build(scheme="http", host=host, port=port)), + self._attr_device_info = get_bsblan_device_info( + data.device, data.info, host, port ) @@ -60,6 +34,32 @@ def __init__(self, coordinator: BSBLanFastCoordinator, data: BSBLanData) -> None super().__init__(coordinator, data) +class BSBLanCircuitEntity(BSBLanEntity): + """BSBLan entity belonging to a heating circuit sub-device.""" + + def __init__( + self, + coordinator: BSBLanFastCoordinator, + data: BSBLanData, + circuit: int, + ) -> None: + """Initialize BSBLan circuit entity with sub-device info.""" + super().__init__(coordinator, data) + mac = data.device.MAC + host = coordinator.config_entry.data[CONF_HOST] + port = coordinator.config_entry.data.get(CONF_PORT, DEFAULT_PORT) + main_info = get_bsblan_device_info(data.device, data.info, host, port) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{mac}-circuit-{circuit}")}, + translation_key="heating_circuit", + translation_placeholders={"circuit": str(circuit)}, + via_device=(DOMAIN, mac), + manufacturer=main_info["manufacturer"], + model=main_info.get("model"), + model_id=main_info.get("model_id"), + ) + + class BSBLanDualCoordinatorEntity(BSBLanEntity): """Entity that listens to both fast and slow coordinators.""" @@ -80,3 +80,28 @@ async def async_added_to_hass(self) -> None: self.async_on_remove( self.slow_coordinator.async_add_listener(self._handle_coordinator_update) ) + + +class BSBLanWaterHeaterDeviceEntity(BSBLanDualCoordinatorEntity): + """BSBLan entity belonging to the water heater sub-device.""" + + def __init__( + self, + fast_coordinator: BSBLanFastCoordinator, + slow_coordinator: BSBLanSlowCoordinator, + data: BSBLanData, + ) -> None: + """Initialize BSBLan water heater sub-device entity.""" + super().__init__(fast_coordinator, slow_coordinator, data) + mac = data.device.MAC + host = fast_coordinator.config_entry.data[CONF_HOST] + port = fast_coordinator.config_entry.data.get(CONF_PORT, DEFAULT_PORT) + main_info = get_bsblan_device_info(data.device, data.info, host, port) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{mac}-water-heater")}, + translation_key="water_heater", + via_device=(DOMAIN, mac), + manufacturer=main_info["manufacturer"], + model=main_info.get("model"), + model_id=main_info.get("model_id"), + ) diff --git a/homeassistant/components/bsblan/quality_scale.yaml b/homeassistant/components/bsblan/quality_scale.yaml index be9efefd13735..f309f63765cef 100644 --- a/homeassistant/components/bsblan/quality_scale.yaml +++ b/homeassistant/components/bsblan/quality_scale.yaml @@ -48,13 +48,10 @@ rules: dynamic-devices: status: exempt comment: | - This integration has a fixed single device. + Devices and sub-devices are determined at config entry setup and do not change at runtime. entity-category: done entity-device-class: done - entity-disabled-by-default: - status: exempt - comment: | - This integration provides a limited number of entities, all of which are useful to users. + entity-disabled-by-default: done entity-translations: done exception-translations: done icon-translations: todo @@ -66,7 +63,7 @@ rules: stale-devices: status: exempt comment: | - This integration has a fixed single device. + Devices and sub-devices are determined at config entry setup and do not change at runtime. # Platinum async-dependency: done diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json index bd663eb8ba7f9..d257119f2a5ad 100644 --- a/homeassistant/components/bsblan/strings.json +++ b/homeassistant/components/bsblan/strings.json @@ -79,6 +79,14 @@ } } }, + "device": { + "heating_circuit": { + "name": "Heating circuit {circuit}" + }, + "water_heater": { + "name": "Water heater" + } + }, "entity": { "button": { "sync_time": { diff --git a/homeassistant/components/bsblan/water_heater.py b/homeassistant/components/bsblan/water_heater.py index c91a9518f7b9a..4f11a52b03d5f 100644 --- a/homeassistant/components/bsblan/water_heater.py +++ b/homeassistant/components/bsblan/water_heater.py @@ -21,7 +21,7 @@ from . import BSBLanConfigEntry, BSBLanData from .const import DOMAIN -from .entity import BSBLanDualCoordinatorEntity +from .entity import BSBLanWaterHeaterDeviceEntity PARALLEL_UPDATES = 1 @@ -61,7 +61,7 @@ async def async_setup_entry( async_add_entities([BSBLANWaterHeater(data)]) -class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity): +class BSBLANWaterHeater(BSBLanWaterHeaterDeviceEntity, WaterHeaterEntity): """Defines a BSBLAN water heater entity.""" _attr_name = None diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py index 296490511cbea..284677e139679 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -213,11 +213,13 @@ ), EcoWittSensorTypes.LIGHTNING_DISTANCE_KM: SensorEntityDescription( key="LIGHTNING_DISTANCE_KM", + device_class=SensorDeviceClass.DISTANCE, native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.MEASUREMENT, ), EcoWittSensorTypes.LIGHTNING_DISTANCE_MILES: SensorEntityDescription( key="LIGHTNING_DISTANCE_MILES", + device_class=SensorDeviceClass.DISTANCE, native_unit_of_measurement=UnitOfLength.MILES, state_class=SensorStateClass.MEASUREMENT, ), diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 46059407294f8..cac4eadfe2548 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -35,6 +35,7 @@ MediaPlayerInfo, MediaPlayerSupportedFormat, NumberInfo, + RadioFrequencyInfo, SelectInfo, SensorInfo, SensorState, @@ -88,6 +89,7 @@ FanInfo: Platform.FAN, InfraredInfo: Platform.INFRARED, LightInfo: Platform.LIGHT, + RadioFrequencyInfo: Platform.RADIO_FREQUENCY, LockInfo: Platform.LOCK, MediaPlayerInfo: Platform.MEDIA_PLAYER, NumberInfo: Platform.NUMBER, diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 3e12097d5a97b..36dc3d1c835d2 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==44.18.0", + "aioesphomeapi==44.21.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.7.3" ], diff --git a/homeassistant/components/esphome/radio_frequency.py b/homeassistant/components/esphome/radio_frequency.py new file mode 100644 index 0000000000000..7aaea22f53d80 --- /dev/null +++ b/homeassistant/components/esphome/radio_frequency.py @@ -0,0 +1,77 @@ +"""Radio Frequency platform for ESPHome.""" + +from __future__ import annotations + +from functools import partial +import logging + +from aioesphomeapi import ( + EntityState, + RadioFrequencyCapability, + RadioFrequencyInfo, + RadioFrequencyModulation, +) +from rf_protocols import ModulationType, RadioFrequencyCommand + +from homeassistant.components.radio_frequency import RadioFrequencyTransmitterEntity +from homeassistant.core import callback + +from .entity import ( + EsphomeEntity, + convert_api_error_ha_error, + platform_async_setup_entry, +) + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + +MODULATION_TYPE_TO_ESPHOME: dict[ModulationType, RadioFrequencyModulation] = { + ModulationType.OOK: RadioFrequencyModulation.OOK, +} + + +class EsphomeRadioFrequencyEntity( + EsphomeEntity[RadioFrequencyInfo, EntityState], RadioFrequencyTransmitterEntity +): + """ESPHome radio frequency entity using native API.""" + + @property + def supported_frequency_ranges(self) -> list[tuple[int, int]]: + """Return supported frequency ranges from device info.""" + return [(self._static_info.frequency_min, self._static_info.frequency_max)] + + @callback + def _on_device_update(self) -> None: + """Call when device updates or entry data changes.""" + super()._on_device_update() + if self._entry_data.available: + self.async_write_ha_state() + + @convert_api_error_ha_error + async def async_send_command(self, command: RadioFrequencyCommand) -> None: + """Send an RF command.""" + timings = command.get_raw_timings() + _LOGGER.debug("Sending RF command: %s", timings) + + self._client.radio_frequency_transmit_raw_timings( + self._static_info.key, + frequency=command.frequency, + timings=timings, + modulation=MODULATION_TYPE_TO_ESPHOME[command.modulation], + # In ESPHome, repeat_count is total number of times to send the command, while in rf_protocols + # it's the number of additional times to send it, so we need to add 1 here. + repeat_count=command.repeat_count + 1, + device_id=self._static_info.device_id, + ) + + +async_setup_entry = partial( + platform_async_setup_entry, + info_type=RadioFrequencyInfo, + entity_type=EsphomeRadioFrequencyEntity, + state_type=EntityState, + info_filter=lambda info: bool( + info.capabilities & RadioFrequencyCapability.TRANSMITTER + ), +) diff --git a/homeassistant/components/fritz/const.py b/homeassistant/components/fritz/const.py index 032efb3f4ae9c..5050907c1d875 100644 --- a/homeassistant/components/fritz/const.py +++ b/homeassistant/components/fritz/const.py @@ -66,8 +66,6 @@ class MeshRoles(StrEnum): BUTTON_TYPE_WOL = "WakeOnLan" -UPTIME_DEVIATION = 5 - FRITZ_EXCEPTIONS = ( ConnectionError, FritzActionError, diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 41fa3fca056dc..4bd54a751d50f 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -28,7 +28,7 @@ from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow -from .const import DSL_CONNECTION, UPTIME_DEVIATION +from .const import DSL_CONNECTION from .coordinator import FritzConfigEntry from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription from .models import ConnectionInfo @@ -39,31 +39,18 @@ PARALLEL_UPDATES = 0 -def _uptime_calculation(seconds_uptime: float, last_value: datetime | None) -> datetime: - """Calculate uptime with deviation.""" - delta_uptime = utcnow() - timedelta(seconds=seconds_uptime) - - if ( - not last_value - or abs((delta_uptime - last_value).total_seconds()) > UPTIME_DEVIATION - ): - return delta_uptime - - return last_value - - def _retrieve_device_uptime_state( - status: FritzStatus, last_value: datetime + status: FritzStatus, last_value: datetime | None ) -> datetime: """Return uptime from device.""" - return _uptime_calculation(status.device_uptime, last_value) + return utcnow() - timedelta(seconds=status.device_uptime) def _retrieve_connection_uptime_state( status: FritzStatus, last_value: datetime | None ) -> datetime: """Return uptime from connection.""" - return _uptime_calculation(status.connection_uptime, last_value) + return utcnow() - timedelta(seconds=status.connection_uptime) def _retrieve_external_ip_state(status: FritzStatus, last_value: str) -> str: @@ -200,7 +187,7 @@ class FritzDeviceSensorEntityDescription( FritzConnectionSensorEntityDescription( key="connection_uptime", translation_key="connection_uptime", - device_class=SensorDeviceClass.TIMESTAMP, + device_class=SensorDeviceClass.UPTIME, entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_connection_uptime_state, ), @@ -308,7 +295,7 @@ class FritzDeviceSensorEntityDescription( FritzDeviceSensorEntityDescription( key="device_uptime", translation_key="device_uptime", - device_class=SensorDeviceClass.TIMESTAMP, + device_class=SensorDeviceClass.UPTIME, entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_device_uptime_state, ), diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index 27c63742f7b88..0fc5618c122fb 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -225,7 +225,7 @@ def update_entity_trigger( elif ( new_state.domain == "sensor" and new_state.attributes.get(ATTR_DEVICE_CLASS) - == sensor.SensorDeviceClass.TIMESTAMP + in (sensor.SensorDeviceClass.TIMESTAMP, sensor.SensorDeviceClass.UPTIME) and new_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) ): trigger_dt = dt_util.parse_datetime(new_state.state) diff --git a/homeassistant/components/honeywell_string_lights/__init__.py b/homeassistant/components/honeywell_string_lights/__init__.py new file mode 100644 index 0000000000000..f5c7b4b09a5d3 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/__init__.py @@ -0,0 +1,20 @@ +"""The Honeywell String Lights integration.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS: list[Platform] = [Platform.LIGHT] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Honeywell String Lights from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/honeywell_string_lights/config_flow.py b/homeassistant/components/honeywell_string_lights/config_flow.py new file mode 100644 index 0000000000000..f659a1403d4b2 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/config_flow.py @@ -0,0 +1,61 @@ +"""Config flow for the Honeywell String Lights integration.""" + +from __future__ import annotations + +from typing import Any + +from rf_protocols import RadioFrequencyCommand +import voluptuous as vol + +from homeassistant.components.radio_frequency import async_get_transmitters +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er, selector + +from .const import CONF_TRANSMITTER, DOMAIN +from .light import COMMANDS + + +class HoneywellStringLightsConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Honeywell String Lights.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + sample_command: RadioFrequencyCommand = await self.hass.async_add_executor_job( + COMMANDS.load_command, "turn_on" + ) + try: + transmitters = async_get_transmitters( + self.hass, sample_command.frequency, sample_command.modulation + ) + except HomeAssistantError: + return self.async_abort(reason="no_transmitters") + + if not transmitters: + return self.async_abort(reason="no_compatible_transmitters") + + if user_input is not None: + registry = er.async_get(self.hass) + entity_entry = registry.async_get(user_input[CONF_TRANSMITTER]) + assert entity_entry is not None + await self.async_set_unique_id(entity_entry.id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title="Honeywell String Lights", + data={CONF_TRANSMITTER: entity_entry.id}, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_TRANSMITTER): selector.EntitySelector( + selector.EntitySelectorConfig(include_entities=transmitters), + ), + } + ), + ) diff --git a/homeassistant/components/honeywell_string_lights/const.py b/homeassistant/components/honeywell_string_lights/const.py new file mode 100644 index 0000000000000..c55c712f6c7a5 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/const.py @@ -0,0 +1,9 @@ +"""Constants for the Honeywell String Lights integration.""" + +from __future__ import annotations + +from typing import Final + +DOMAIN: Final = "honeywell_string_lights" + +CONF_TRANSMITTER: Final = "transmitter" diff --git a/homeassistant/components/honeywell_string_lights/entity.py b/homeassistant/components/honeywell_string_lights/entity.py new file mode 100644 index 0000000000000..76363e1efa427 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/entity.py @@ -0,0 +1,76 @@ +"""Common entity for Honeywell String Lights integration.""" + +from __future__ import annotations + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import Event, EventStateChangedData, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_state_change_event + +from .const import CONF_TRANSMITTER, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class HoneywellStringLightsEntity(Entity): + """Honeywell String Lights base entity.""" + + _attr_has_entity_name = True + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize the entity.""" + self._transmitter = entry.data[CONF_TRANSMITTER] + self._attr_unique_id = entry.entry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + manufacturer="Honeywell", + model="String Lights", + ) + + async def async_added_to_hass(self) -> None: + """Subscribe to transmitter entity state changes.""" + await super().async_added_to_hass() + + transmitter_entity_id = er.async_validate_entity_id( + er.async_get(self.hass), self._transmitter + ) + + @callback + def _async_transmitter_state_changed( + event: Event[EventStateChangedData], + ) -> None: + """Handle transmitter entity state changes.""" + new_state = event.data["new_state"] + transmitter_available = ( + new_state is not None and new_state.state != STATE_UNAVAILABLE + ) + if transmitter_available != self.available: + _LOGGER.info( + "Transmitter %s used by %s is %s", + transmitter_entity_id, + self.entity_id, + "available" if transmitter_available else "unavailable", + ) + + self._attr_available = transmitter_available + self.async_write_ha_state() + + self.async_on_remove( + async_track_state_change_event( + self.hass, + [transmitter_entity_id], + _async_transmitter_state_changed, + ) + ) + + # Set initial availability based on current transmitter entity state + transmitter_state = self.hass.states.get(transmitter_entity_id) + self._attr_available = ( + transmitter_state is not None + and transmitter_state.state != STATE_UNAVAILABLE + ) diff --git a/homeassistant/components/honeywell_string_lights/light.py b/homeassistant/components/honeywell_string_lights/light.py new file mode 100644 index 0000000000000..d430e1f90e8c6 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/light.py @@ -0,0 +1,65 @@ +"""Light platform for Honeywell String Lights.""" + +from __future__ import annotations + +from typing import Any + +from rf_protocols import get_codes + +from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.components.radio_frequency import async_send_command +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +from .entity import HoneywellStringLightsEntity + +PARALLEL_UPDATES = 1 + +COMMANDS = get_codes("honeywell/string_lights") + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Honeywell String Lights light platform.""" + async_add_entities([HoneywellStringLight(config_entry)]) + + +class HoneywellStringLight(HoneywellStringLightsEntity, LightEntity, RestoreEntity): + """Representation of a Honeywell String Lights set controlled via RF.""" + + _attr_assumed_state = True + _attr_color_mode = ColorMode.ONOFF + _attr_supported_color_modes = {ColorMode.ONOFF} + _attr_name = None + _attr_should_poll = False + + async def async_added_to_hass(self) -> None: + """Restore last known state.""" + await super().async_added_to_hass() + if (last_state := await self.async_get_last_state()) is not None: + self._attr_is_on = last_state.state == STATE_ON + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the light.""" + await self._async_send_command("turn_on") + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" + await self._async_send_command("turn_off") + self._attr_is_on = False + self.async_write_ha_state() + + async def _async_send_command(self, name: str) -> None: + """Load the named command and send it via the configured transmitter.""" + command = await COMMANDS.async_load_command(name) + await async_send_command( + self.hass, self._transmitter, command, context=self._context + ) diff --git a/homeassistant/components/honeywell_string_lights/manifest.json b/homeassistant/components/honeywell_string_lights/manifest.json new file mode 100644 index 0000000000000..9924b71141463 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "honeywell_string_lights", + "name": "Honeywell String Lights", + "codeowners": ["@balloob"], + "config_flow": true, + "dependencies": ["radio_frequency"], + "documentation": "https://www.home-assistant.io/integrations/honeywell_string_lights", + "integration_type": "device", + "iot_class": "assumed_state", + "quality_scale": "bronze", + "requirements": ["rf-protocols==2.1.0"] +} diff --git a/homeassistant/components/honeywell_string_lights/quality_scale.yaml b/homeassistant/components/honeywell_string_lights/quality_scale.yaml new file mode 100644 index 0000000000000..54bcb3f12c1a0 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/quality_scale.yaml @@ -0,0 +1,124 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not register custom service actions. + appropriate-polling: + status: exempt + comment: | + This integration transmits RF commands and does not poll. + 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 register custom service 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: + status: exempt + comment: | + This integration does not use runtime data. + test-before-configure: + status: exempt + comment: | + RF transmission is a one-way broadcast with no device to contact. + test-before-setup: + status: exempt + comment: | + RF transmission is a one-way broadcast with no device to contact. + unique-config-entry: done + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration has no options. + docs-installation-parameters: todo + entity-unavailable: + status: exempt + comment: | + RF transmission is a one-way broadcast; the light uses assumed state. + integration-owner: done + log-when-unavailable: + status: exempt + comment: | + RF transmission is a one-way broadcast; the light uses assumed state. + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + This integration does not authenticate. + test-coverage: todo + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + This integration does not support discovery. + discovery: + status: exempt + comment: | + RF devices cannot be discovered. + docs-data-update: + status: exempt + comment: | + RF transmission is one-way; there is no data update. + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + Each config entry represents a single static device. + entity-category: + status: exempt + comment: | + The single entity represents the primary device function. + entity-device-class: + status: exempt + comment: | + Light entities do not have device classes. + entity-disabled-by-default: + status: exempt + comment: | + The single entity represents the primary device function. + entity-translations: + status: exempt + comment: | + The entity uses the device name. + exception-translations: todo + icon-translations: + status: exempt + comment: | + Light uses the default icon for its state. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + No known repairable issues. + stale-devices: + status: exempt + comment: | + Each config entry represents a single static device. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + This integration does not use a web session. + strict-typing: todo diff --git a/homeassistant/components/honeywell_string_lights/strings.json b/homeassistant/components/honeywell_string_lights/strings.json new file mode 100644 index 0000000000000..a5c995ace0870 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_compatible_transmitters": "No radio frequency transmitter supports 433.92 MHz OOK transmissions. Please add a compatible transmitter first.", + "no_transmitters": "No radio frequency transmitters are available. Please set up a transmitter first." + }, + "step": { + "user": { + "data": { + "transmitter": "Radio frequency transmitter" + }, + "data_description": { + "transmitter": "The radio frequency transmitter used to control the Honeywell String Lights." + } + } + } + } +} diff --git a/homeassistant/components/hr_energy_qube/binary_sensor.py b/homeassistant/components/hr_energy_qube/binary_sensor.py new file mode 100644 index 0000000000000..71312cbb2115d --- /dev/null +++ b/homeassistant/components/hr_energy_qube/binary_sensor.py @@ -0,0 +1,293 @@ +"""Binary sensor platform for Qube Heat Pump.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from python_qube_heatpump.models import QubeState + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory + +from .entity import QubeEntity + +PARALLEL_UPDATES = 0 + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + + from . import QubeConfigEntry + from .coordinator import QubeCoordinator + + +@dataclass(frozen=True, kw_only=True) +class QubeBinarySensorEntityDescription(BinarySensorEntityDescription): + """Binary sensor entity description for Qube Heat Pump.""" + + value_fn: Callable[[QubeState], bool | None] + + +BINARY_SENSOR_TYPES: tuple[QubeBinarySensorEntityDescription, ...] = ( + # Outputs + QubeBinarySensorEntityDescription( + key="source_pump", + translation_key="source_pump", + value_fn=lambda data: data.dout_srcpmp_val, + ), + QubeBinarySensorEntityDescription( + key="user_pump", + translation_key="user_pump", + value_fn=lambda data: data.dout_usrpmp_val, + ), + QubeBinarySensorEntityDescription( + key="four_way_valve", + translation_key="four_way_valve", + value_fn=lambda data: data.dout_fourwayvlv_val, + ), + QubeBinarySensorEntityDescription( + key="cooling_output", + translation_key="cooling_output", + value_fn=lambda data: data.dout_cooling_val, + ), + QubeBinarySensorEntityDescription( + key="three_way_valve", + translation_key="three_way_valve", + value_fn=lambda data: data.dout_threewayvlv_val, + ), + QubeBinarySensorEntityDescription( + key="buffer_pump", + translation_key="buffer_pump", + value_fn=lambda data: data.dout_bufferpmp_val, + ), + QubeBinarySensorEntityDescription( + key="heater_step_1", + translation_key="heater_step_1", + value_fn=lambda data: data.dout_heaterstep1_val, + ), + QubeBinarySensorEntityDescription( + key="heater_step_2", + translation_key="heater_step_2", + value_fn=lambda data: data.dout_heaterstep2_val, + ), + QubeBinarySensorEntityDescription( + key="heater_step_3", + translation_key="heater_step_3", + value_fn=lambda data: data.dout_heaterstep3_val, + ), + # System status + QubeBinarySensorEntityDescription( + key="keypad", + translation_key="keypad", + value_fn=lambda data: data.keybonoff, + ), + QubeBinarySensorEntityDescription( + key="day_mode", + translation_key="day_mode", + value_fn=lambda data: data.daynightmode, + ), + # Alarms + QubeBinarySensorEntityDescription( + key="alarm_antilegionella_timeout", + translation_key="alarm_antilegionella_timeout", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.al_maxtime_antileg_active, + ), + QubeBinarySensorEntityDescription( + key="alarm_dhw_timeout", + translation_key="alarm_dhw_timeout", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.al_maxtime_dhw_active, + ), + QubeBinarySensorEntityDescription( + key="alarm_dewpoint", + translation_key="alarm_dewpoint", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.al_dewpoint_active, + ), + QubeBinarySensorEntityDescription( + key="alarm_supply_too_hot", + translation_key="alarm_supply_too_hot", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.al_underfloorsafety_active, + ), + QubeBinarySensorEntityDescription( + key="alarm_flow", + translation_key="alarm_flow", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.alrm_flw, + ), + QubeBinarySensorEntityDescription( + key="alarm_central_heating", + translation_key="alarm_central_heating", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.usralrms, + ), + QubeBinarySensorEntityDescription( + key="alarm_cooling", + translation_key="alarm_cooling", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.coolingalrms, + ), + QubeBinarySensorEntityDescription( + key="alarm_heating", + translation_key="alarm_heating", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.heatingalrms, + ), + QubeBinarySensorEntityDescription( + key="alarm_working_hours", + translation_key="alarm_working_hours", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.alarmmng_al_workinghour, + ), + QubeBinarySensorEntityDescription( + key="alarm_source", + translation_key="alarm_source", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.srsalrm, + ), + QubeBinarySensorEntityDescription( + key="alarm_global", + translation_key="alarm_global", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.glbal, + ), + QubeBinarySensorEntityDescription( + key="alarm_compressor", + translation_key="alarm_compressor", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.alarmmng_al_pwrplus, + ), + # Sensor/controller status + QubeBinarySensorEntityDescription( + key="room_sensor_enabled", + translation_key="room_sensor_enabled", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.roomprb_en, + ), + QubeBinarySensorEntityDescription( + key="plant_sensor_enabled", + translation_key="plant_sensor_enabled", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.plantprb_en, + ), + QubeBinarySensorEntityDescription( + key="buffer_sensor_enabled", + translation_key="buffer_sensor_enabled", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.bufferprb_en, + ), + QubeBinarySensorEntityDescription( + key="dhw_controller_enabled", + translation_key="dhw_controller_enabled", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.en_dhwpid, + ), + # Demand signals + QubeBinarySensorEntityDescription( + key="plant_demand", + translation_key="plant_demand", + value_fn=lambda data: data.plantdemand, + ), + QubeBinarySensorEntityDescription( + key="external_demand", + translation_key="external_demand", + value_fn=lambda data: data.id_demand, + ), + QubeBinarySensorEntityDescription( + key="thermostat_demand", + translation_key="thermostat_demand", + value_fn=lambda data: data.thermostatdemand, + ), + # Digital inputs + QubeBinarySensorEntityDescription( + key="summer_mode", + translation_key="summer_mode", + value_fn=lambda data: data.id_summerwinter, + ), + QubeBinarySensorEntityDescription( + key="dewpoint", + translation_key="dewpoint", + value_fn=lambda data: data.dewpoint, + ), + QubeBinarySensorEntityDescription( + key="booster_security", + translation_key="booster_security", + value_fn=lambda data: data.boostersecurity, + ), + QubeBinarySensorEntityDescription( + key="source_flow", + translation_key="source_flow", + value_fn=lambda data: data.srcflw, + ), + QubeBinarySensorEntityDescription( + key="anti_legionella", + translation_key="anti_legionella", + value_fn=lambda data: data.req_antileg_1, + ), + # Energy + QubeBinarySensorEntityDescription( + key="pv_surplus", + translation_key="pv_surplus", + value_fn=lambda data: data.surplus_pv, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: QubeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Qube binary sensors.""" + coordinator = entry.runtime_data.coordinator + + async_add_entities( + QubeBinarySensor(coordinator, entry, description) + for description in BINARY_SENSOR_TYPES + ) + + +class QubeBinarySensor(QubeEntity, BinarySensorEntity): + """Qube binary sensor entity.""" + + entity_description: QubeBinarySensorEntityDescription + + def __init__( + self, + coordinator: QubeCoordinator, + entry: QubeConfigEntry, + description: QubeBinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator, entry) + self.entity_description = description + self._attr_unique_id = f"{entry.entry_id}-{description.key}" + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/hr_energy_qube/const.py b/homeassistant/components/hr_energy_qube/const.py index a71233fd8032f..1f9ea9a820c1f 100644 --- a/homeassistant/components/hr_energy_qube/const.py +++ b/homeassistant/components/hr_energy_qube/const.py @@ -3,7 +3,7 @@ from homeassistant.const import Platform DOMAIN = "hr_energy_qube" -PLATFORMS = (Platform.SENSOR,) +PLATFORMS = (Platform.BINARY_SENSOR, Platform.SENSOR) DEFAULT_PORT = 502 DEFAULT_SCAN_INTERVAL = 15 diff --git a/homeassistant/components/hr_energy_qube/strings.json b/homeassistant/components/hr_energy_qube/strings.json index e2b87ed5c74b5..72aec7675124c 100644 --- a/homeassistant/components/hr_energy_qube/strings.json +++ b/homeassistant/components/hr_energy_qube/strings.json @@ -20,6 +20,116 @@ } }, "entity": { + "binary_sensor": { + "alarm_antilegionella_timeout": { + "name": "Anti-legionella timeout alarm" + }, + "alarm_central_heating": { + "name": "Central heating alarm" + }, + "alarm_compressor": { + "name": "Compressor alarm" + }, + "alarm_cooling": { + "name": "Cooling alarm" + }, + "alarm_dewpoint": { + "name": "Dewpoint alarm" + }, + "alarm_dhw_timeout": { + "name": "DHW timeout alarm" + }, + "alarm_flow": { + "name": "Flow alarm" + }, + "alarm_global": { + "name": "Global alarm" + }, + "alarm_heating": { + "name": "Heating alarm" + }, + "alarm_source": { + "name": "Source alarm" + }, + "alarm_supply_too_hot": { + "name": "Supply too hot alarm" + }, + "alarm_working_hours": { + "name": "Working hours alarm" + }, + "anti_legionella": { + "name": "Anti-legionella" + }, + "booster_security": { + "name": "Booster security" + }, + "buffer_pump": { + "name": "Buffer pump" + }, + "buffer_sensor_enabled": { + "name": "Buffer sensor enabled" + }, + "cooling_output": { + "name": "Cooling output" + }, + "day_mode": { + "name": "Day mode" + }, + "dewpoint": { + "name": "Dewpoint" + }, + "dhw_controller_enabled": { + "name": "DHW controller enabled" + }, + "external_demand": { + "name": "External demand" + }, + "four_way_valve": { + "name": "Four-way valve" + }, + "heater_step_1": { + "name": "Heater step 1" + }, + "heater_step_2": { + "name": "Heater step 2" + }, + "heater_step_3": { + "name": "Heater step 3" + }, + "keypad": { + "name": "Keypad" + }, + "plant_demand": { + "name": "Plant demand" + }, + "plant_sensor_enabled": { + "name": "Plant sensor enabled" + }, + "pv_surplus": { + "name": "PV surplus" + }, + "room_sensor_enabled": { + "name": "Room sensor enabled" + }, + "source_flow": { + "name": "Source flow" + }, + "source_pump": { + "name": "Source pump" + }, + "summer_mode": { + "name": "Summer mode" + }, + "thermostat_demand": { + "name": "Thermostat demand" + }, + "three_way_valve": { + "name": "Three-way valve" + }, + "user_pump": { + "name": "User pump" + } + }, "sensor": { "compressor_speed": { "name": "Compressor speed" diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 90165ef6d8fbb..6ce5d6d56d195 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -182,6 +182,9 @@ def async_validate_signed_request(request: Request) -> bool: if refresh_token is None: return False + if async_user_not_allowed_do_auth(hass, refresh_token.user, request): + return False + request[KEY_HASS_USER] = refresh_token.user request[KEY_HASS_REFRESH_TOKEN_ID] = refresh_token.id return True diff --git a/homeassistant/components/indevolt/manifest.json b/homeassistant/components/indevolt/manifest.json index 33d0a3d360ad5..04034c9661d0c 100644 --- a/homeassistant/components/indevolt/manifest.json +++ b/homeassistant/components/indevolt/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["indevolt-api==1.3.1"] + "requirements": ["indevolt-api==1.4.2"] } diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 6bf5896dd7030..52d79e37f43ba 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -62,6 +62,7 @@ Platform.LAWN_MOWER, Platform.LOCK, Platform.NOTIFY, + Platform.RADIO_FREQUENCY, Platform.SENSOR, Platform.SWITCH, Platform.WEATHER, diff --git a/homeassistant/components/kitchen_sink/radio_frequency.py b/homeassistant/components/kitchen_sink/radio_frequency.py new file mode 100644 index 0000000000000..c11983ffe5a92 --- /dev/null +++ b/homeassistant/components/kitchen_sink/radio_frequency.py @@ -0,0 +1,67 @@ +"""Demo platform that offers a fake radio frequency entity.""" + +from __future__ import annotations + +from rf_protocols import RadioFrequencyCommand + +from homeassistant.components import persistent_notification +from homeassistant.components.radio_frequency import RadioFrequencyTransmitterEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import DOMAIN + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the demo radio frequency platform.""" + async_add_entities( + [ + DemoRadioFrequency( + unique_id="rf_transmitter", + device_name="RF Blaster", + entity_name="Radio Frequency Transmitter", + ), + ] + ) + + +class DemoRadioFrequency(RadioFrequencyTransmitterEntity): + """Representation of a demo radio frequency entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__( + self, + unique_id: str, + device_name: str, + entity_name: str, + ) -> None: + """Initialize the demo radio frequency entity.""" + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=device_name, + ) + self._attr_name = entity_name + + @property + def supported_frequency_ranges(self) -> list[tuple[int, int]]: + """Return supported frequency ranges.""" + return [(300_000_000, 928_000_000)] + + async def async_send_command(self, command: RadioFrequencyCommand) -> None: + """Send an RF command.""" + persistent_notification.async_create( + self.hass, + str(command.get_raw_timings()), + title="Radio Frequency Command", + ) diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index f8aebacd056c7..7a445a815a485 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -37,6 +37,7 @@ Platform.CAMERA.value: vol.All(cv.ensure_list, [dict]), Platform.CLIMATE.value: vol.All(cv.ensure_list, [dict]), Platform.COVER.value: vol.All(cv.ensure_list, [dict]), + Platform.DATE.value: vol.All(cv.ensure_list, [dict]), Platform.DEVICE_TRACKER.value: vol.All(cv.ensure_list, [dict]), Platform.EVENT.value: vol.All(cv.ensure_list, [dict]), Platform.FAN.value: vol.All(cv.ensure_list, [dict]), diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 7f01c2f745f84..be02529a87f6f 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -401,6 +401,7 @@ Platform.CAMERA, Platform.CLIMATE, Platform.COVER, + Platform.DATE, Platform.DEVICE_TRACKER, Platform.EVENT, Platform.FAN, @@ -433,6 +434,7 @@ "camera", "climate", "cover", + "date", "device_automation", "device_tracker", "event", diff --git a/homeassistant/components/mqtt/date.py b/homeassistant/components/mqtt/date.py new file mode 100644 index 0000000000000..369b094c98abe --- /dev/null +++ b/homeassistant/components/mqtt/date.py @@ -0,0 +1,156 @@ +"""Support for MQTT date platform.""" + +from __future__ import annotations + +from collections.abc import Callable +import datetime +import logging +from typing import Any + +from dateutil.parser import ParserError, parse +import voluptuous as vol + +from homeassistant.components import date +from homeassistant.components.date import DateEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.service_info.mqtt import ReceivePayloadType +from homeassistant.helpers.typing import ConfigType, VolSchemaType + +from . import subscription +from .config import MQTT_RW_SCHEMA +from .const import ( + CONF_COMMAND_TEMPLATE, + CONF_COMMAND_TOPIC, + CONF_STATE_TOPIC, + PAYLOAD_NONE, +) +from .entity import MqttEntity, async_setup_entity_entry_helper +from .models import ( + MqttCommandTemplate, + MqttValueTemplate, + PublishPayloadType, + ReceiveMessage, +) +from .schemas import MQTT_ENTITY_COMMON_SCHEMA + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + +DEFAULT_NAME = "MQTT Date" + +MQTT_TIME_ATTRIBUTES_BLOCKED: frozenset[str] = frozenset() + + +PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( + { + vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + }, +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + + +DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up MQTT date through YAML and through MQTT discovery.""" + async_setup_entity_entry_helper( + hass, + config_entry, + MqttDateEntity, + date.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, + ) + + +class MqttDateEntity(MqttEntity, DateEntity): + """Representation of the MQTT date entity.""" + + _attr_native_value: datetime.date | None = None + _attributes_extra_blocked = MQTT_TIME_ATTRIBUTES_BLOCKED + _default_name = DEFAULT_NAME + _entity_id_format = date.ENTITY_ID_FORMAT + + _optimistic: bool + _command_template: Callable[ + [PublishPayloadType, dict[str, Any]], PublishPayloadType + ] + _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] + + @staticmethod + def config_schema() -> VolSchemaType: + """Return the config schema.""" + return DISCOVERY_SCHEMA + + def _setup_from_config(self, config: ConfigType) -> None: + """(Re)Setup the entity.""" + self._command_template = MqttCommandTemplate( + config.get(CONF_COMMAND_TEMPLATE), + entity=self, + ).async_render + self._value_template = MqttValueTemplate( + config.get(CONF_VALUE_TEMPLATE), + entity=self, + ).async_render_with_possible_json_value + optimistic: bool = config[CONF_OPTIMISTIC] + self._optimistic = optimistic or config.get(CONF_STATE_TOPIC) is None + self._attr_assumed_state = bool(self._optimistic) + + @callback + def _handle_state_message_received(self, msg: ReceiveMessage) -> None: + """Handle receiving state message via MQTT.""" + payload = str(self._value_template(msg.payload)) + if payload == PAYLOAD_NONE: + self._attr_native_value = None + return + if payload == "": + _LOGGER.debug( + "Ignoring empty state payload on topic %s for entity %s", + msg.topic, + self.entity_id, + ) + return + try: + value = parse(payload) + except ParserError: + _LOGGER.warning( + "Invalid received date expression on topic %s for entity %s, got %s", + msg.topic, + self.entity_id, + msg.payload, + ) + else: + self._attr_native_value = value.date() + + @callback + def _prepare_subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + self.add_subscription( + CONF_STATE_TOPIC, + self._handle_state_message_received, + {"_attr_native_value"}, + ) + + async def _subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) + + async def async_set_value(self, value: datetime.date) -> None: + """Change the date.""" + payload = self._command_template(value.isoformat(), {"value": value}) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) + if self._optimistic: + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/otp/strings.json b/homeassistant/components/otp/strings.json index af811a8ab2c5c..1e654bbd5a73d 100644 --- a/homeassistant/components/otp/strings.json +++ b/homeassistant/components/otp/strings.json @@ -13,6 +13,9 @@ "data": { "code": "Verification code (OTP)" }, + "data_description": { + "code": "The six-digit code currently displayed in your authentication app." + }, "description": "Before completing the setup of One-Time Password (OTP), confirm with a verification code. Scan the QR code with your authentication app. If you don't have one, we recommend either {auth_app1} or {auth_app2}.\n\nAfter scanning the code, enter the six-digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**.", "title": "Verify One-Time Password (OTP)" }, @@ -21,7 +24,13 @@ "name": "[%key:common::config_flow::data::name%]", "new_token": "Generate a new token?", "token": "Authenticator token (OTP)" - } + }, + "data_description": { + "name": "The purpose of this sensor (for example, the name of the service or account for which the One-Time Password is used).", + "new_token": "Generate a new secret key. You will be able to scan a QR code to import this token into your preferred authenticator app in the next step.", + "token": "An existing secret key for import into Home Assistant." + }, + "description": "Creates a sensor that generates One-Time Passwords (OTP) for two-factor authentication." } } } diff --git a/homeassistant/components/radio_frequency/__init__.py b/homeassistant/components/radio_frequency/__init__.py new file mode 100644 index 0000000000000..c7c58f64df23c --- /dev/null +++ b/homeassistant/components/radio_frequency/__init__.py @@ -0,0 +1,228 @@ +"""Provides functionality to interact with radio frequency devices.""" + +from __future__ import annotations + +from abc import abstractmethod +from datetime import timedelta +import logging +from typing import final + +from rf_protocols import ModulationType, RadioFrequencyCommand + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import ConfigType +from homeassistant.util import dt as dt_util +from homeassistant.util.hass_dict import HassKey + +from .const import DOMAIN + +__all__ = [ + "DOMAIN", + "ModulationType", + "RadioFrequencyTransmitterEntity", + "RadioFrequencyTransmitterEntityDescription", + "async_get_transmitters", + "async_send_command", +] + +_LOGGER = logging.getLogger(__name__) + +DATA_COMPONENT: HassKey[EntityComponent[RadioFrequencyTransmitterEntity]] = HassKey( + DOMAIN +) +ENTITY_ID_FORMAT = DOMAIN + ".{}" +PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA +PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE +SCAN_INTERVAL = timedelta(seconds=30) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the radio_frequency domain.""" + component = hass.data[DATA_COMPONENT] = EntityComponent[ + RadioFrequencyTransmitterEntity + ](_LOGGER, DOMAIN, hass, SCAN_INTERVAL) + await component.async_setup(config) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) + + +@callback +def async_get_transmitters( + hass: HomeAssistant, + frequency: int, + modulation: ModulationType, +) -> list[str]: + """Get entity IDs of all RF transmitters supporting the given frequency. + + Transmitters are filtered by both their supported frequency ranges and + their supported modulation types. An empty list means no compatible + transmitters. + + Raises: + HomeAssistantError: If the component is not loaded or if no + transmitters exist. + """ + component = hass.data.get(DATA_COMPONENT) + if component is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="component_not_loaded", + ) + + entities = list(component.entities) + if not entities: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="no_transmitters", + ) + + return [ + entity.entity_id + for entity in entities + if entity.supports_modulation(modulation) + and entity.supports_frequency(frequency) + ] + + +async def async_send_command( + hass: HomeAssistant, + entity_id_or_uuid: str, + command: RadioFrequencyCommand, + context: Context | None = None, +) -> None: + """Send an RF command to the specified radio_frequency entity. + + Raises: + vol.Invalid: If `entity_id_or_uuid` is not a valid entity ID or known entity + registry UUID. + HomeAssistantError: If the radio_frequency component is not loaded or the + resolved entity is not found. + """ + component = hass.data.get(DATA_COMPONENT) + if component is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="component_not_loaded", + ) + + ent_reg = er.async_get(hass) + entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid) + entity = component.get_entity(entity_id) + if entity is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="entity_not_found", + translation_placeholders={"entity_id": entity_id}, + ) + + if not entity.supports_frequency(command.frequency): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_frequency", + translation_placeholders={ + "entity_id": entity_id, + "frequency": str(command.frequency), + }, + ) + + if not entity.supports_modulation(command.modulation): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_modulation", + translation_placeholders={ + "entity_id": entity_id, + "modulation": command.modulation, + }, + ) + + if context is not None: + entity.async_set_context(context) + + await entity.async_send_command_internal(command) + + +class RadioFrequencyTransmitterEntityDescription( + EntityDescription, frozen_or_thawed=True +): + """Describes radio frequency transmitter entities.""" + + +class RadioFrequencyTransmitterEntity(RestoreEntity): + """Base class for radio frequency transmitter entities.""" + + entity_description: RadioFrequencyTransmitterEntityDescription + _attr_should_poll = False + _attr_state: None = None + + __last_command_sent: str | None = None + + @property + @abstractmethod + def supported_frequency_ranges(self) -> list[tuple[int, int]]: + """Return list of (min_hz, max_hz) tuples.""" + + @callback + @final + def supports_frequency(self, frequency: int) -> bool: + """Return whether the transmitter supports the given frequency.""" + return any( + low <= frequency <= high for low, high in self.supported_frequency_ranges + ) + + @callback + @final + def supports_modulation(self, modulation: ModulationType) -> bool: + """Return whether the transmitter supports the given modulation.""" + return modulation == ModulationType.OOK + + @property + @final + def state(self) -> str | None: + """Return the entity state.""" + return self.__last_command_sent + + @final + async def async_send_command_internal(self, command: RadioFrequencyCommand) -> None: + """Send an RF command and update state. + + Should not be overridden, handles setting last sent timestamp. + """ + await self.async_send_command(command) + self.__last_command_sent = dt_util.utcnow().isoformat(timespec="milliseconds") + self.async_write_ha_state() + + @final + async def async_internal_added_to_hass(self) -> None: + """Call when the radio frequency entity is added to hass.""" + await super().async_internal_added_to_hass() + state = await self.async_get_last_state() + if state is not None and state.state not in (STATE_UNAVAILABLE, None): + self.__last_command_sent = state.state + + @abstractmethod + async def async_send_command(self, command: RadioFrequencyCommand) -> None: + """Send an RF command. + + Args: + command: The RF command to send. + + Raises: + HomeAssistantError: If transmission fails. + """ diff --git a/homeassistant/components/radio_frequency/const.py b/homeassistant/components/radio_frequency/const.py new file mode 100644 index 0000000000000..04d50de7d8ed1 --- /dev/null +++ b/homeassistant/components/radio_frequency/const.py @@ -0,0 +1,5 @@ +"""Constants for the Radio Frequency integration.""" + +from typing import Final + +DOMAIN: Final = "radio_frequency" diff --git a/homeassistant/components/radio_frequency/icons.json b/homeassistant/components/radio_frequency/icons.json new file mode 100644 index 0000000000000..c7587d1f77070 --- /dev/null +++ b/homeassistant/components/radio_frequency/icons.json @@ -0,0 +1,7 @@ +{ + "entity_component": { + "_": { + "default": "mdi:radio-tower" + } + } +} diff --git a/homeassistant/components/radio_frequency/manifest.json b/homeassistant/components/radio_frequency/manifest.json new file mode 100644 index 0000000000000..0c346c011b67e --- /dev/null +++ b/homeassistant/components/radio_frequency/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "radio_frequency", + "name": "Radio Frequency", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/radio_frequency", + "integration_type": "entity", + "quality_scale": "internal", + "requirements": ["rf-protocols==2.1.0"] +} diff --git a/homeassistant/components/radio_frequency/strings.json b/homeassistant/components/radio_frequency/strings.json new file mode 100644 index 0000000000000..9674cd260236a --- /dev/null +++ b/homeassistant/components/radio_frequency/strings.json @@ -0,0 +1,19 @@ +{ + "exceptions": { + "component_not_loaded": { + "message": "Radio Frequency component not loaded" + }, + "entity_not_found": { + "message": "Radio Frequency entity `{entity_id}` not found" + }, + "no_transmitters": { + "message": "No Radio Frequency transmitters available" + }, + "unsupported_frequency": { + "message": "Radio Frequency entity `{entity_id}` does not support frequency {frequency} Hz" + }, + "unsupported_modulation": { + "message": "Radio Frequency entity `{entity_id}` does not support modulation {modulation}" + } + } +} diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 3148b0d13c2ac..73ea6db503653 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import Mapping +from collections.abc import Callable, Mapping from contextlib import suppress from dataclasses import dataclass from datetime import UTC, date, datetime, timedelta @@ -32,6 +32,7 @@ from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum from homeassistant.util.hass_dict import HassKey +from homeassistant.util.variance import ignore_variance from .const import ( # noqa: F401 AMBIGUOUS_UNITS, @@ -63,6 +64,8 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE SCAN_INTERVAL: Final = timedelta(seconds=30) +UPTIME_DEFAULT_TOLERANCE_SECONDS: Final = 60 +UPTIME_MIN_TOLERANCE_SECONDS: Final = 5 __all__ = [ "ATTR_LAST_RESET", @@ -180,6 +183,9 @@ def _calculate_precision_from_ratio( class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for sensor entities.""" + # Allow per-entity override of drift tolerance + _attr_uptime_drift_tolerance: int = UPTIME_DEFAULT_TOLERANCE_SECONDS + _entity_component_unrecorded_attributes = frozenset({ATTR_OPTIONS}) entity_description: SensorEntityDescription @@ -201,6 +207,19 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _sensor_option_display_precision: int | None = None _sensor_option_unit_of_measurement: str | None | UndefinedType = UNDEFINED _invalid_suggested_unit_of_measurement_reported = False + _get_uptime: Callable[[datetime], datetime] | None = None + + def _normalize_uptime(self, current_uptime: datetime) -> datetime: + """Normalize uptime to suppress small drift between updates.""" + if self._get_uptime is None: + drift_tolerance = max( + self._attr_uptime_drift_tolerance, UPTIME_MIN_TOLERANCE_SECONDS + ) + self._get_uptime = ignore_variance( + func=lambda value: value, + ignored_variance=timedelta(seconds=drift_tolerance), + ) + return self._get_uptime(current_uptime) @callback def add_to_platform_start( @@ -610,10 +629,14 @@ def state(self) -> Any: # Checks below only apply if there is a value if value is None: + if device_class is SensorDeviceClass.UPTIME: + # Reset baseline so the first uptime after unavailable is not + # compared against a stale value. + self._get_uptime = None return None # Received a datetime - if device_class is SensorDeviceClass.TIMESTAMP: + if device_class in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME): try: # We cast the value, to avoid using isinstance, but satisfy # typechecking. The errors are guarded in this try. @@ -627,10 +650,13 @@ def state(self) -> Any: if value.tzinfo != UTC: value = value.astimezone(UTC) + if device_class is SensorDeviceClass.UPTIME: + value = self._normalize_uptime(value) + return value.isoformat(timespec="seconds") except (AttributeError, OverflowError, TypeError) as err: raise ValueError( - f"Invalid datetime: {self.entity_id} has timestamp device class " + f"Invalid datetime: {self.entity_id} has {device_class.value} device class " f"but provides state {value}:{type(value)} resulting in '{err}'" ) from err diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 26fde24059699..85dd700e5ef1a 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -117,6 +117,20 @@ class SensorDeviceClass(StrEnum): ISO8601 format: https://en.wikipedia.org/wiki/ISO_8601 """ + UPTIME = "uptime" + """Uptime. + + Represents the point in time when a device or service last restarted. + + Small drift between updates is automatically suppressed in + `SensorEntity.state` to avoid unnecessary state changes caused by clock + jitter. + + Unit of measurement: `None` + + ISO8601 format: https://en.wikipedia.org/wiki/ISO_8601 + """ + # Numerical device classes, these should be aligned with NumberDeviceClass ABSOLUTE_HUMIDITY = "absolute_humidity" """Absolute humidity. @@ -516,6 +530,7 @@ class SensorDeviceClass(StrEnum): SensorDeviceClass.DATE, SensorDeviceClass.ENUM, SensorDeviceClass.TIMESTAMP, + SensorDeviceClass.UPTIME, } DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(SensorDeviceClass)) @@ -816,6 +831,7 @@ class SensorStateClass(StrEnum): SensorDeviceClass.TEMPERATURE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.TEMPERATURE_DELTA: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.TIMESTAMP: set(), + SensorDeviceClass.UPTIME: set(), SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.VOLTAGE: {SensorStateClass.MEASUREMENT}, diff --git a/homeassistant/components/sensor/helpers.py b/homeassistant/components/sensor/helpers.py index 12a5dcefdf8d0..c404c697da003 100644 --- a/homeassistant/components/sensor/helpers.py +++ b/homeassistant/components/sensor/helpers.py @@ -18,7 +18,7 @@ def async_parse_date_datetime( value: str, entity_id: str, device_class: SensorDeviceClass | str | None ) -> datetime | date | None: """Parse datetime string to a data or datetime.""" - if device_class == SensorDeviceClass.TIMESTAMP: + if device_class in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME): if (parsed_timestamp := dt_util.parse_datetime(value)) is None: _LOGGER.warning("%s rendered invalid timestamp: %s", entity_id, value) return None diff --git a/homeassistant/components/sensor/icons.json b/homeassistant/components/sensor/icons.json index 59d57da280346..966e19439e38b 100644 --- a/homeassistant/components/sensor/icons.json +++ b/homeassistant/components/sensor/icons.json @@ -163,6 +163,9 @@ "timestamp": { "default": "mdi:clock" }, + "uptime": { + "default": "mdi:clock-start" + }, "volatile_organic_compounds": { "default": "mdi:molecule" }, diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 33b56f1b0f1df..e51c139e8dea8 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -297,6 +297,9 @@ "timestamp": { "name": "Timestamp" }, + "uptime": { + "name": "Uptime" + }, "volatile_organic_compounds": { "name": "Volatile organic compounds" }, diff --git a/homeassistant/components/tractive/manifest.json b/homeassistant/components/tractive/manifest.json index 96abbf24adecb..200bda0d885a2 100644 --- a/homeassistant/components/tractive/manifest.json +++ b/homeassistant/components/tractive/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_push", "loggers": ["aiotractive"], - "requirements": ["aiotractive==1.0.2"] + "requirements": ["aiotractive==1.0.3"] } diff --git a/homeassistant/components/victron_gx/quality_scale.yaml b/homeassistant/components/victron_gx/quality_scale.yaml index 03df94c76148b..2e5e179f42db9 100644 --- a/homeassistant/components/victron_gx/quality_scale.yaml +++ b/homeassistant/components/victron_gx/quality_scale.yaml @@ -45,7 +45,7 @@ rules: diagnostics: done discovery-update-info: done discovery: done - docs-data-update: todo + docs-data-update: done docs-examples: done docs-known-limitations: done docs-supported-devices: done @@ -63,9 +63,11 @@ rules: comment: | Not relevant. reconfiguration-flow: done - repair-issues: todo + repair-issues: + status: exempt + comment: | + This integration has no user-actionable repair issues to raise. stale-devices: done - # Platinum async-dependency: done inject-websession: diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index b99e865112186..85725361bcf28 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -1028,10 +1028,13 @@ async def handle_test_condition( # Do static + dynamic validation of the condition config = await async_validate_condition_config(hass, msg["condition"]) # Test the condition - check_condition = await async_condition_from_config(hass, config) - connection.send_result( - msg["id"], {"result": check_condition(hass, msg.get("variables"))} - ) + condition = await async_condition_from_config(hass, config) + try: + connection.send_result( + msg["id"], {"result": condition.async_check(variables=msg.get("variables"))} + ) + finally: + condition.async_unload() @decorators.websocket_command( diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 810338d54878d..fbd8bed90f3e3 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -311,6 +311,7 @@ "homewizard", "homeworks", "honeywell", + "honeywell_string_lights", "hr_energy_qube", "html5", "huawei_lte", diff --git a/homeassistant/generated/entity_platforms.py b/homeassistant/generated/entity_platforms.py index 718c3745be890..ac97ac50c71f7 100644 --- a/homeassistant/generated/entity_platforms.py +++ b/homeassistant/generated/entity_platforms.py @@ -36,6 +36,7 @@ class EntityPlatforms(StrEnum): MEDIA_PLAYER = "media_player" NOTIFY = "notify" NUMBER = "number" + RADIO_FREQUENCY = "radio_frequency" REMOTE = "remote" SCENE = "scene" SELECT = "select" diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 550fe74d22ae8..f7466b5891b28 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2975,6 +2975,12 @@ "config_flow": true, "iot_class": "cloud_polling", "name": "Honeywell Total Connect Comfort (US)" + }, + "honeywell_string_lights": { + "integration_type": "device", + "config_flow": true, + "iot_class": "assumed_state", + "name": "Honeywell String Lights" } } }, diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 112de4e4d8bda..878c136c26c98 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -360,15 +360,15 @@ def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> None: class CompoundConditionChecker(ConditionChecker): """Base class for compound condition checkers (and/or/not).""" - def __init__(self, hass: HomeAssistant, checks: list[ConditionChecker]) -> None: + def __init__(self, hass: HomeAssistant, conditions: list[ConditionChecker]) -> None: """Initialize condition checker.""" super().__init__(hass) - self._checks = checks + self._conditions = conditions def async_unload(self) -> None: """Clean up child conditions.""" - for check in self._checks: - check.async_unload() + for condition in self._conditions: + condition.async_unload() super().async_unload() @@ -1012,15 +1012,15 @@ class AndConditionChecker(CompoundConditionChecker): def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: """Test and condition.""" errors = [] - for index, check in enumerate(self._checks): + for index, condition in enumerate(self._conditions): try: with trace_path(["conditions", str(index)]): - if check(self._hass, **kwargs) is False: + if condition.async_check(**kwargs) is False: return False except ConditionError as ex: errors.append( ConditionErrorIndex( - "and", index=index, total=len(self._checks), error=ex + "and", index=index, total=len(self._conditions), error=ex ) ) @@ -1046,15 +1046,15 @@ class OrConditionChecker(CompoundConditionChecker): def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: """Test or condition.""" errors = [] - for index, check in enumerate(self._checks): + for index, condition in enumerate(self._conditions): try: with trace_path(["conditions", str(index)]): - if check(self._hass, **kwargs) is True: + if condition.async_check(**kwargs) is True: return True except ConditionError as ex: errors.append( ConditionErrorIndex( - "or", index=index, total=len(self._checks), error=ex + "or", index=index, total=len(self._conditions), error=ex ) ) @@ -1080,15 +1080,15 @@ class NotConditionChecker(CompoundConditionChecker): def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: """Test not condition.""" errors = [] - for index, check in enumerate(self._checks): + for index, condition in enumerate(self._conditions): try: with trace_path(["conditions", str(index)]): - if check(self._hass, **kwargs): + if condition.async_check(**kwargs): return False except ConditionError as ex: errors.append( ConditionErrorIndex( - "not", index=index, total=len(self._checks), error=ex + "not", index=index, total=len(self._conditions), error=ex ) ) @@ -1476,7 +1476,7 @@ def time( after = datetime.strptime(after_entity.state, "%H:%M:%S").time() elif ( after_entity.attributes.get(ATTR_DEVICE_CLASS) - == SensorDeviceClass.TIMESTAMP + in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME) ) and after_entity.state not in ( STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -1506,7 +1506,7 @@ def time( return False elif ( before_entity.attributes.get(ATTR_DEVICE_CLASS) - == SensorDeviceClass.TIMESTAMP + in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME) ) and before_entity.state not in ( STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -1646,40 +1646,81 @@ async def async_conditions_from_config( condition_configs: list[ConfigType], logger: logging.Logger, name: str, -) -> Callable[[TemplateVarsType], bool]: +) -> ConditionsChecker: """AND all conditions.""" checks = [ await async_from_config(hass, condition_config) for condition_config in condition_configs ] + return ConditionsChecker(checks, logger, name) - def check_conditions(variables: TemplateVarsType = None) -> bool: + +class ConditionsChecker: + """Condition checker that ANDs multiple conditions. + + Used by automations and template entities. Unlike AndConditionChecker, + this logs warnings on errors instead of raising, and uses "condition" + as the trace path prefix. + """ + + def __init__( + self, + conditions: list[ConditionChecker], + logger: logging.Logger, + name: str, + ) -> None: + """Initialize condition checker.""" + self._conditions = conditions + self._logger = logger + self._name = name + self._unloaded = False + + def __call__(self, variables: TemplateVarsType = None) -> bool: + """Check all conditions.""" + return self.async_check(variables=variables) + + def __del__(self) -> None: + """Clean up when the checker is deleted.""" + if self._unloaded: + return + try: + self.async_unload() + except Exception: + _LOGGER.exception("Error while unloading condition checker") + + def async_unload(self) -> None: + """Clean up child conditions.""" + self._unloaded = True + for condition in self._conditions: + condition.async_unload() + + def async_check( + self, *, variables: TemplateVarsType = None, **kwargs: Never + ) -> bool: """AND all conditions.""" errors: list[ConditionErrorIndex] = [] - for index, check in enumerate(checks): + for index, condition in enumerate(self._conditions): try: with trace_path(["condition", str(index)]): - if check(hass, variables) is False: + if condition.async_check(variables=variables, **kwargs) is False: return False except ConditionError as ex: errors.append( ConditionErrorIndex( - "condition", index=index, total=len(checks), error=ex + "condition", index=index, total=len(self._conditions), error=ex ) ) if errors: - logger.warning( + self._logger.warning( "Error evaluating condition in '%s':\n%s", - name, + self._name, ConditionErrorContainer("condition", errors=errors), ) return False return True - return check_conditions - @callback def async_extract_entities(config: ConfigType | Template) -> set[str]: diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 98f23ecd47e71..0947ddc90d1db 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -92,7 +92,7 @@ template, trigger as trigger_helper, ) -from .condition import ConditionCheckerTypeOptional, trace_condition_function +from .condition import ConditionChecker, trace_condition_function from .dispatcher import async_dispatcher_connect, async_dispatcher_send_internal from .event import async_call_later, async_track_template from .script_variables import ScriptRunVariables, ScriptVariables @@ -682,14 +682,12 @@ async def _async_step_sequence(self) -> None: ### Condition actions ### - async def _async_get_condition( - self, config: ConfigType - ) -> ConditionCheckerTypeOptional: + async def _async_get_condition(self, config: ConfigType) -> ConditionChecker: return await self._script._async_get_condition(config) # noqa: SLF001 def _test_conditions( self, - conditions: list[ConditionCheckerTypeOptional], + conditions: list[ConditionChecker], name: str, condition_path: str | None = None, ) -> bool | None: @@ -1413,12 +1411,12 @@ def _referenced_extract_ids(data: Any, key: str, found: set[str]) -> None: class _ChooseData(TypedDict): - choices: list[tuple[list[ConditionCheckerTypeOptional], Script]] + choices: list[tuple[list[ConditionChecker], Script]] default: Script | None class _IfData(TypedDict): - if_conditions: list[ConditionCheckerTypeOptional] + if_conditions: list[ConditionChecker] if_then: Script if_else: Script | None @@ -1495,16 +1493,24 @@ def __init__( self._max_exceeded = max_exceeded if script_mode == SCRIPT_MODE_QUEUED: self._queue_lck = asyncio.Lock() - self._config_cache: dict[ - frozenset[tuple[str, str]], ConditionCheckerTypeOptional - ] = {} + self._condition_cache: dict[frozenset[tuple[str, str]], ConditionChecker] = {} self._repeat_script: dict[int, Script] = {} self._choose_data: dict[int, _ChooseData] = {} self._if_data: dict[int, _IfData] = {} self._parallel_scripts: dict[int, list[Script]] = {} self._sequence_scripts: dict[int, Script] = {} + self._unloaded = False self.variables = variables + def __del__(self) -> None: + """Clean up when the script is deleted.""" + if self._unloaded: + return + try: + self.async_unload() + except Exception: + _LOGGER.exception("Error while unloading script") + @property def change_listener(self) -> Callable[..., Any] | None: """Return the change_listener.""" @@ -1889,13 +1895,56 @@ async def async_stop( return await asyncio.shield(create_eager_task(self._async_stop(aws, update_state))) - async def _async_get_condition( - self, config: ConfigType - ) -> ConditionCheckerTypeOptional: + def async_unload(self) -> None: + """Unload the script, cleaning up all resources. + + Unloads cached conditions, and recursively unloads sub-scripts. + The script must not be running when this is called; sub-scripts + are guaranteed to not be running if the parent is not running. + """ + if self._runs: + raise RuntimeError( + f"Cannot unload script '{self.name}' while it is running" + ) + self._unloaded = True + + for cond in self._condition_cache.values(): + cond.async_unload() + self._condition_cache.clear() + + for sub_script in self._repeat_script.values(): + sub_script.async_unload() + self._repeat_script.clear() + + # Conditions in _choose_data and _if_data are the same objects as in + # _condition_cache, so they're already unloaded above. Only unload scripts. + for choose_data in self._choose_data.values(): + for _conditions, sub_script in choose_data["choices"]: + sub_script.async_unload() + if choose_data["default"] is not None: + choose_data["default"].async_unload() + self._choose_data.clear() + + for if_data in self._if_data.values(): + if_data["if_then"].async_unload() + if if_data["if_else"] is not None: + if_data["if_else"].async_unload() + self._if_data.clear() + + for scripts in self._parallel_scripts.values(): + for sub_script in scripts: + sub_script.async_unload() + self._parallel_scripts.clear() + + for sub_script in self._sequence_scripts.values(): + sub_script.async_unload() + self._sequence_scripts.clear() + + async def _async_get_condition(self, config: ConfigType) -> ConditionChecker: config_cache_key = frozenset((k, str(v)) for k, v in config.items()) - if not (cond := self._config_cache.get(config_cache_key)): + if not (cond := self._condition_cache.get(config_cache_key)): cond = await condition.async_from_config(self._hass, config) - self._config_cache[config_cache_key] = cond + self._condition_cache[config_cache_key] = cond return cond def _prep_repeat_script(self, step: int) -> Script: diff --git a/homeassistant/helpers/trigger_template_entity.py b/homeassistant/helpers/trigger_template_entity.py index dc8f52763c32e..87d03c0115274 100644 --- a/homeassistant/helpers/trigger_template_entity.py +++ b/homeassistant/helpers/trigger_template_entity.py @@ -403,12 +403,13 @@ def __init__( def _set_native_value_with_possible_timestamp(self, value: Any) -> None: """Set native value with possible timestamp. - If self.device_class is `date` or `timestamp`, + If self.device_class is `date`, `timestamp`, or `uptime`, it will try to parse the value to a date/datetime object. """ if self.device_class not in ( SensorDeviceClass.DATE, SensorDeviceClass.TIMESTAMP, + SensorDeviceClass.UPTIME, ): self._attr_native_value = value elif value is not None: diff --git a/pylint/plugins/hass_enforce_config_entry_unique_id_no_ip.py b/pylint/plugins/hass_enforce_config_entry_unique_id_no_ip.py index c0c41adc2f7a9..f41dae4ac1ec7 100644 --- a/pylint/plugins/hass_enforce_config_entry_unique_id_no_ip.py +++ b/pylint/plugins/hass_enforce_config_entry_unique_id_no_ip.py @@ -124,34 +124,18 @@ def _check_subscript_or_call_ip(node: nodes.NodeNG) -> str | None: avoiding false positives from local variables named ``host`` used in attribute chains like ``host.api.mac_address``. """ - # Subscript: data[CONF_HOST] or data["host"] - if isinstance(node, nodes.Subscript): - key = node.slice - if isinstance(key, nodes.Name) and key.name in _IP_HOST_NAMES: - return str(key.name) - if ( - isinstance(key, nodes.Const) - and isinstance(key.value, str) - and key.value in _IP_HOST_NAMES - ): - return key.value - - # Call: data.get(CONF_HOST) or data.get("host") - if ( - isinstance(node, nodes.Call) - and isinstance(node.func, nodes.Attribute) - and node.func.attrname == "get" - and node.args - ): - first_arg = node.args[0] - if isinstance(first_arg, nodes.Name) and first_arg.name in _IP_HOST_NAMES: - return str(first_arg.name) - if ( - isinstance(first_arg, nodes.Const) - and isinstance(first_arg.value, str) - and first_arg.value in _IP_HOST_NAMES - ): - return first_arg.value + match node: + # Subscript: data[CONF_HOST] or data["host"] + case nodes.Subscript( + slice=nodes.Name(name=val) | nodes.Const(value=str(val)) + ) if val in _IP_HOST_NAMES: + return str(val) + # Call: data.get(CONF_HOST) or data.get("host") + case nodes.Call( + func=nodes.Attribute(attrname="get"), + args=[nodes.Name(name=val) | nodes.Const(value=str(val)), *_], + ) if val in _IP_HOST_NAMES: + return str(val) # Recurse into child nodes to catch embedded references (e.g. f-strings), # but skip function call arguments -- a call like get_unique_id(host) diff --git a/pylint/plugins/hass_enforce_config_flow_no_polling.py b/pylint/plugins/hass_enforce_config_flow_no_polling.py index d45df89c0924b..1666633fdda7b 100644 --- a/pylint/plugins/hass_enforce_config_flow_no_polling.py +++ b/pylint/plugins/hass_enforce_config_flow_no_polling.py @@ -83,19 +83,14 @@ def visit_call(self, node: nodes.Call) -> None: def _get_schema_field_name(node: nodes.Call) -> str | None: """Extract the field name from vol.Required(...) or vol.Optional(...).""" - if not isinstance(node.func, nodes.Attribute): - return None - if node.func.attrname not in {"Required", "Optional"}: - return None - if not node.args: - return None - - first_arg = node.args[0] - if isinstance(first_arg, nodes.Const) and isinstance(first_arg.value, str): - return first_arg.value - if isinstance(first_arg, nodes.Name): - return str(first_arg.name) - return None + match node: + case nodes.Call( + func=nodes.Attribute(attrname="Required" | "Optional"), + args=[nodes.Name(name=val) | nodes.Const(value=str(val)), *_], + ): + return str(val) + case _: + return None def register(linter: PyLinter) -> None: diff --git a/pylint/plugins/hass_enforce_runtime_data.py b/pylint/plugins/hass_enforce_runtime_data.py index 95548c5cd7a59..b9e636c1ea68a 100644 --- a/pylint/plugins/hass_enforce_runtime_data.py +++ b/pylint/plugins/hass_enforce_runtime_data.py @@ -81,39 +81,31 @@ def visit_subscript(self, node: nodes.Subscript) -> None: return # Don't flag deletion: del hass.data[DOMAIN] or hass.data[DOMAIN].pop(...) - parent = node.parent - if isinstance(parent, nodes.Delete): - return - if ( - isinstance(parent, nodes.Attribute) - and parent.attrname == "pop" - and isinstance(parent.parent, nodes.Call) - ): - return + match node.parent: + case nodes.Delete(): + return + case nodes.Attribute(attrname="pop", parent=nodes.Call()): + return self.add_message("hass-use-runtime-data", node=node) def _is_hass_data_domain_access(node: nodes.Subscript) -> bool: """Return True if node is hass.data[DOMAIN] or self.hass.data[DOMAIN].""" - if not isinstance(node.value, nodes.Attribute): - return False - if node.value.attrname != "data": - return False - - slice_node = node.slice - if not isinstance(slice_node, nodes.Name) or slice_node.name != "DOMAIN": - return False - - expr = node.value.expr - if isinstance(expr, nodes.Name) and expr.name == "hass": - return True - return ( - isinstance(expr, nodes.Attribute) - and expr.attrname == "hass" - and isinstance(expr.expr, nodes.Name) - and expr.expr.name == "self" - ) + match node: + case nodes.Subscript( + value=nodes.Attribute( + expr=( + nodes.Name(name="hass") + | nodes.Attribute(expr=nodes.Name(name="self"), attrname="hass") + ), + attrname="data", + ), + slice=nodes.Name(name="DOMAIN"), + ): + return True + case _: + return False def _has_config_flow(integration: str, module: nodes.Module) -> bool: diff --git a/requirements.txt b/requirements.txt index e9b326456c22f..dd720c4db0071 100644 --- a/requirements.txt +++ b/requirements.txt @@ -47,6 +47,7 @@ python-slugify==8.0.4 PyTurboJPEG==1.8.3 PyYAML==6.0.3 requests==2.33.1 +rf-protocols==2.1.0 securetar==2026.4.1 SQLAlchemy==2.0.49 standard-aifc==3.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 50db74a453add..d25547077ef5e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -251,7 +251,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==44.18.0 +aioesphomeapi==44.21.0 # homeassistant.components.matrix # homeassistant.components.slack @@ -429,7 +429,7 @@ aiotankerkoenig==0.5.1 aiotedee==0.3.0 # homeassistant.components.tractive -aiotractive==1.0.2 +aiotractive==1.0.3 # homeassistant.components.unifi aiounifi==90 @@ -1329,7 +1329,7 @@ imgw_pib==2.1.1 incomfort-client==0.7.0 # homeassistant.components.indevolt -indevolt-api==1.3.1 +indevolt-api==1.4.2 # homeassistant.components.influxdb influxdb-client==1.50.0 @@ -2840,6 +2840,10 @@ renson-endura-delta==1.7.2 # homeassistant.components.reolink reolink-aio==0.19.1 +# homeassistant.components.honeywell_string_lights +# homeassistant.components.radio_frequency +rf-protocols==2.1.0 + # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5b031f5cef31f..17c1d0d087cf6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -242,7 +242,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==44.18.0 +aioesphomeapi==44.21.0 # homeassistant.components.matrix # homeassistant.components.slack @@ -414,7 +414,7 @@ aiotankerkoenig==0.5.1 aiotedee==0.3.0 # homeassistant.components.tractive -aiotractive==1.0.2 +aiotractive==1.0.3 # homeassistant.components.unifi aiounifi==90 @@ -1181,7 +1181,7 @@ imgw_pib==2.1.1 incomfort-client==0.7.0 # homeassistant.components.indevolt -indevolt-api==1.3.1 +indevolt-api==1.4.2 # homeassistant.components.influxdb influxdb-client==1.50.0 @@ -2424,6 +2424,10 @@ renson-endura-delta==1.7.2 # homeassistant.components.reolink reolink-aio==0.19.1 +# homeassistant.components.honeywell_string_lights +# homeassistant.components.radio_frequency +rf-protocols==2.1.0 + # homeassistant.components.rflink rflink==0.0.67 diff --git a/tests/components/airq/conftest.py b/tests/components/airq/conftest.py index 21118c3ef27e5..dce3f5777d82b 100644 --- a/tests/components/airq/conftest.py +++ b/tests/components/airq/conftest.py @@ -5,7 +5,21 @@ import pytest -from .common import TEST_BRIGHTNESS, TEST_DEVICE_DATA, TEST_DEVICE_INFO +from homeassistant.components.airq.const import DOMAIN + +from .common import TEST_BRIGHTNESS, TEST_DEVICE_DATA, TEST_DEVICE_INFO, TEST_USER_DATA + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Create a mock config entry for air-Q.""" + return MockConfigEntry( + data=TEST_USER_DATA, + domain=DOMAIN, + unique_id=TEST_DEVICE_INFO["id"], + ) @pytest.fixture diff --git a/tests/components/airq/test_config_flow.py b/tests/components/airq/test_config_flow.py index 880c801a9a20d..92b82e978db2f 100644 --- a/tests/components/airq/test_config_flow.py +++ b/tests/components/airq/test_config_flow.py @@ -1,7 +1,6 @@ """Test the air-Q config flow.""" from ipaddress import IPv4Address -import logging from unittest.mock import AsyncMock from aioairq import InvalidAuth @@ -41,106 +40,109 @@ } -async def test_form( +async def test_user_flow( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, mock_airq: AsyncMock, ) -> None: - """Test we get the form.""" - caplog.set_level(logging.DEBUG) + """Test successful user config flow from start to entry creation.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["errors"] is None - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], TEST_USER_DATA, ) await hass.async_block_till_done() - assert f"Creating an entry for {TEST_DEVICE_INFO['name']}" in caplog.text - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == TEST_DEVICE_INFO["name"] - assert result2["data"] == TEST_USER_DATA + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_DEVICE_INFO["name"] + assert result["data"] == TEST_USER_DATA + assert result["result"].unique_id == TEST_DEVICE_INFO["id"] -async def test_form_invalid_auth(hass: HomeAssistant, mock_airq: AsyncMock) -> None: - """Test we handle invalid auth.""" +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (InvalidAuth, "invalid_auth"), + (ClientConnectionError, "cannot_connect"), + ], +) +async def test_user_flow_errors_recover( + hass: HomeAssistant, + mock_airq: AsyncMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test user flow recovers from errors and completes successfully.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_airq.validate.side_effect = InvalidAuth - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_USER_DATA | {CONF_PASSWORD: "wrong_password"} + mock_airq.validate.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_DATA ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} - - -async def test_form_cannot_connect(hass: HomeAssistant, mock_airq: AsyncMock) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} - mock_airq.validate.side_effect = ClientConnectionError - result2 = await hass.config_entries.flow.async_configure( + # Recover: correct input on retry + mock_airq.validate.side_effect = None + result = await hass.config_entries.flow.async_configure( result["flow_id"], TEST_USER_DATA ) + await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_DEVICE_INFO["name"] + assert result["data"] == TEST_USER_DATA -async def test_duplicate_error(hass: HomeAssistant, mock_airq: AsyncMock) -> None: +async def test_duplicate_error( + hass: HomeAssistant, + mock_airq: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test that errors are shown when duplicates are added.""" - MockConfigEntry( - data=TEST_USER_DATA, - domain=DOMAIN, - unique_id=TEST_DEVICE_INFO["id"], - ).add_to_hass(hass) + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], TEST_USER_DATA ) - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" @pytest.mark.parametrize( "user_input", [{}, {CONF_RETURN_AVERAGE: False}, {CONF_CLIP_NEGATIVE: False}] ) -async def test_options_flow(hass: HomeAssistant, user_input) -> None: +async def test_options_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + user_input: dict, +) -> None: """Test that the options flow works.""" - entry = MockConfigEntry( - domain=DOMAIN, data=TEST_USER_DATA, unique_id=TEST_DEVICE_INFO["id"] - ) - entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - assert entry.options == {} + assert mock_config_entry.options == {} result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=user_input ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == entry.options == DEFAULT_OPTIONS | user_input + assert result["data"] == mock_config_entry.options == DEFAULT_OPTIONS | user_input async def test_zeroconf_discovery(hass: HomeAssistant, mock_airq: AsyncMock) -> None: @@ -192,25 +194,25 @@ async def test_zeroconf_discovery_errors( assert result["step_id"] == "discovery_confirm" mock_airq.validate.side_effect = side_effect - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_PASSWORD: "wrong_password"}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": expected_error} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} # Recover: correct password on retry mock_airq.validate.side_effect = None - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "correct_password"}, ) await hass.async_block_till_done() - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "My air-Q" - assert result3["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "My air-Q" + assert result["data"] == { CONF_IP_ADDRESS: "192.168.0.123", CONF_PASSWORD: "correct_password", } diff --git a/tests/components/bsblan/conftest.py b/tests/components/bsblan/conftest.py index 2ffaf857cc63b..a3a00b255b7af 100644 --- a/tests/components/bsblan/conftest.py +++ b/tests/components/bsblan/conftest.py @@ -15,7 +15,11 @@ ) import pytest -from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN +from homeassistant.components.bsblan.const import ( + CONF_HEATING_CIRCUITS, + CONF_PASSKEY, + DOMAIN, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from tests.common import MockConfigEntry, load_fixture @@ -33,8 +37,31 @@ def mock_config_entry() -> MockConfigEntry: CONF_PASSKEY: "1234", CONF_USERNAME: "admin", CONF_PASSWORD: "admin1234", + CONF_HEATING_CIRCUITS: [1], + }, + unique_id="00:80:41:19:69:90", + version=1, + minor_version=2, + ) + + +@pytest.fixture +def mock_config_entry_dual_circuit() -> MockConfigEntry: + """Return a mocked config entry with dual heating circuits.""" + return MockConfigEntry( + title="BSBLAN Setup", + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + CONF_HEATING_CIRCUITS: [1, 2], }, unique_id="00:80:41:19:69:90", + version=1, + minor_version=2, ) @@ -82,5 +109,7 @@ def mock_bsblan() -> Generator[MagicMock]: ) # mock get_temperature_unit property bsblan.get_temperature_unit = "°C" + # Default: single circuit (for config flow tests) + bsblan.get_available_circuits.return_value = [1] yield bsblan diff --git a/tests/components/bsblan/snapshots/test_climate.ambr b/tests/components/bsblan/snapshots/test_climate.ambr index 5f71b17202c8c..9c13a0620f90e 100644 --- a/tests/components/bsblan/snapshots/test_climate.ambr +++ b/tests/components/bsblan/snapshots/test_climate.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_celsius_fahrenheit[climate.bsb_lan-entry] +# name: test_celsius_fahrenheit[climate.heating_circuit_1-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -25,7 +25,7 @@ 'disabled_by': None, 'domain': 'climate', 'entity_category': None, - 'entity_id': 'climate.bsb_lan', + 'entity_id': 'climate.heating_circuit_1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -48,11 +48,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_celsius_fahrenheit[climate.bsb_lan-state] +# name: test_celsius_fahrenheit[climate.heating_circuit_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 18.6, - 'friendly_name': 'BSB-LAN', + 'friendly_name': 'Heating circuit 1', 'hvac_action': , 'hvac_modes': list([ , @@ -70,14 +70,14 @@ 'temperature': 18.5, }), 'context': , - 'entity_id': 'climate.bsb_lan', + 'entity_id': 'climate.heating_circuit_1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'heat', }) # --- -# name: test_climate_entity_properties[climate.bsb_lan-entry] +# name: test_climate_entity_properties[climate.heating_circuit_1-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -103,7 +103,7 @@ 'disabled_by': None, 'domain': 'climate', 'entity_category': None, - 'entity_id': 'climate.bsb_lan', + 'entity_id': 'climate.heating_circuit_1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -126,11 +126,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_climate_entity_properties[climate.bsb_lan-state] +# name: test_climate_entity_properties[climate.heating_circuit_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 18.6, - 'friendly_name': 'BSB-LAN', + 'friendly_name': 'Heating circuit 1', 'hvac_action': , 'hvac_modes': list([ , @@ -148,7 +148,7 @@ 'temperature': 18.5, }), 'context': , - 'entity_id': 'climate.bsb_lan', + 'entity_id': 'climate.heating_circuit_1', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/bsblan/snapshots/test_diagnostics.ambr b/tests/components/bsblan/snapshots/test_diagnostics.ambr index 28baa7ea6db8f..2b88f0775d5d3 100644 --- a/tests/components/bsblan/snapshots/test_diagnostics.ambr +++ b/tests/components/bsblan/snapshots/test_diagnostics.ambr @@ -1,6 +1,9 @@ # serializer version: 1 # name: test_diagnostics dict({ + 'available_circuits': list([ + 1, + ]), 'device': dict({ 'MAC': '00:80:41:19:69:90', 'name': 'BSB-LAN', @@ -116,72 +119,74 @@ 'value': 7968, }), }), - 'state': dict({ - 'current_temperature': dict({ - 'data_type': 0, - 'data_type_family': '', - 'data_type_name': '', - 'desc': '', - 'error': 0, - 'name': 'Room temp 1 actual value', - 'precision': None, - 'readonly': 1, - 'readwrite': 0, - 'unit': '°C', - 'value': 18.6, - }), - 'hvac_action': dict({ - 'data_type': 1, - 'data_type_family': '', - 'data_type_name': '', - 'desc': 'Raumtemp’begrenzung', - 'error': 0, - 'name': 'Status heating circuit 1', - 'precision': None, - 'readonly': 1, - 'readwrite': 0, - 'unit': '', - 'value': 122, - }), - 'hvac_mode': dict({ - 'data_type': 1, - 'data_type_family': '', - 'data_type_name': '', - 'desc': 'Komfort', - 'error': 0, - 'name': 'Operating mode', - 'precision': None, - 'readonly': 0, - 'readwrite': 0, - 'unit': '', - 'value': 3, - }), - 'hvac_mode_changeover': None, - 'room1_temp_setpoint_boost': dict({ - 'data_type': 1, - 'data_type_family': '', - 'data_type_name': '', - 'desc': 'Boost', - 'error': 0, - 'name': 'Room 1 Temp Setpoint Boost', - 'precision': None, - 'readonly': 1, - 'readwrite': 0, - 'unit': '°C', - 'value': 22.5, - }), - 'target_temperature': dict({ - 'data_type': 0, - 'data_type_family': '', - 'data_type_name': '', - 'desc': '', - 'error': 0, - 'name': 'Room temperature Comfort setpoint', - 'precision': None, - 'readonly': 0, - 'readwrite': 0, - 'unit': '°C', - 'value': 18.5, + 'states': dict({ + '1': dict({ + 'current_temperature': dict({ + 'data_type': 0, + 'data_type_family': '', + 'data_type_name': '', + 'desc': '', + 'error': 0, + 'name': 'Room temp 1 actual value', + 'precision': None, + 'readonly': 1, + 'readwrite': 0, + 'unit': '°C', + 'value': 18.6, + }), + 'hvac_action': dict({ + 'data_type': 1, + 'data_type_family': '', + 'data_type_name': '', + 'desc': 'Raumtemp’begrenzung', + 'error': 0, + 'name': 'Status heating circuit 1', + 'precision': None, + 'readonly': 1, + 'readwrite': 0, + 'unit': '', + 'value': 122, + }), + 'hvac_mode': dict({ + 'data_type': 1, + 'data_type_family': '', + 'data_type_name': '', + 'desc': 'Komfort', + 'error': 0, + 'name': 'Operating mode', + 'precision': None, + 'readonly': 0, + 'readwrite': 0, + 'unit': '', + 'value': 3, + }), + 'hvac_mode_changeover': None, + 'room1_temp_setpoint_boost': dict({ + 'data_type': 1, + 'data_type_family': '', + 'data_type_name': '', + 'desc': 'Boost', + 'error': 0, + 'name': 'Room 1 Temp Setpoint Boost', + 'precision': None, + 'readonly': 1, + 'readwrite': 0, + 'unit': '°C', + 'value': 22.5, + }), + 'target_temperature': dict({ + 'data_type': 0, + 'data_type_family': '', + 'data_type_name': '', + 'desc': '', + 'error': 0, + 'name': 'Room temperature Comfort setpoint', + 'precision': None, + 'readonly': 0, + 'readwrite': 0, + 'unit': '°C', + 'value': 18.5, + }), }), }), }), @@ -449,31 +454,33 @@ }), }), 'static': dict({ - 'max_temp': dict({ - 'data_type': 0, - 'data_type_family': '', - 'data_type_name': '', - 'desc': '', - 'error': 0, - 'name': 'Summer/winter changeover temp heat circuit 1', - 'precision': None, - 'readonly': 0, - 'readwrite': 0, - 'unit': '°C', - 'value': 20.0, - }), - 'min_temp': dict({ - 'data_type': 0, - 'data_type_family': '', - 'data_type_name': '', - 'desc': '', - 'error': 0, - 'name': 'Room temp frost protection setpoint', - 'precision': None, - 'readonly': 0, - 'readwrite': 0, - 'unit': '°C', - 'value': 8.0, + '1': dict({ + 'max_temp': dict({ + 'data_type': 0, + 'data_type_family': '', + 'data_type_name': '', + 'desc': '', + 'error': 0, + 'name': 'Summer/winter changeover temp heat circuit 1', + 'precision': None, + 'readonly': 0, + 'readwrite': 0, + 'unit': '°C', + 'value': 20.0, + }), + 'min_temp': dict({ + 'data_type': 0, + 'data_type_family': '', + 'data_type_name': '', + 'desc': '', + 'error': 0, + 'name': 'Room temp frost protection setpoint', + 'precision': None, + 'readonly': 0, + 'readwrite': 0, + 'unit': '°C', + 'value': 8.0, + }), }), }), }) diff --git a/tests/components/bsblan/snapshots/test_water_heater.ambr b/tests/components/bsblan/snapshots/test_water_heater.ambr index 4bbcb6297f7a7..19ae489aab5cd 100644 --- a/tests/components/bsblan/snapshots/test_water_heater.ambr +++ b/tests/components/bsblan/snapshots/test_water_heater.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_water_heater_states[dhw_state.json][water_heater.bsb_lan-entry] +# name: test_water_heater_states[dhw_state.json][water_heater.water_heater-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -21,7 +21,7 @@ 'disabled_by': None, 'domain': 'water_heater', 'entity_category': None, - 'entity_id': 'water_heater.bsb_lan', + 'entity_id': 'water_heater.water_heater', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -44,11 +44,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_water_heater_states[dhw_state.json][water_heater.bsb_lan-state] +# name: test_water_heater_states[dhw_state.json][water_heater.water_heater-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 48.5, - 'friendly_name': 'BSB-LAN', + 'friendly_name': 'Water heater', 'max_temp': 65.0, 'min_temp': 35.0, 'operation_list': list([ @@ -63,7 +63,7 @@ 'temperature': 50.0, }), 'context': , - 'entity_id': 'water_heater.bsb_lan', + 'entity_id': 'water_heater.water_heater', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/bsblan/test_climate.py b/tests/components/bsblan/test_climate.py index 632b78ad237c3..aacb5e36361ba 100644 --- a/tests/components/bsblan/test_climate.py +++ b/tests/components/bsblan/test_climate.py @@ -29,7 +29,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform -ENTITY_ID = "climate.bsb_lan" +ENTITY_ID = "climate.heating_circuit_1" async def test_celsius_fahrenheit( @@ -270,7 +270,7 @@ async def test_async_set_hvac_mode( # Assert that the thermostat method was called with integer value expected_int = HA_TO_BSBLAN_HVAC_MODE_TEST[mode] - mock_bsblan.thermostat.assert_called_once_with(hvac_mode=expected_int) + mock_bsblan.thermostat.assert_called_once_with(hvac_mode=expected_int, circuit=1) mock_bsblan.thermostat.reset_mock() @@ -332,7 +332,9 @@ async def test_async_set_temperature( blocking=True, ) # Assert that the thermostat method was called with the correct temperature - mock_bsblan.thermostat.assert_called_once_with(target_temperature=target_temp) + mock_bsblan.thermostat.assert_called_once_with( + target_temperature=target_temp, circuit=1 + ) async def test_async_set_data( @@ -350,7 +352,7 @@ async def test_async_set_data( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 19}, blocking=True, ) - mock_bsblan.thermostat.assert_called_once_with(target_temperature=19) + mock_bsblan.thermostat.assert_called_once_with(target_temperature=19, circuit=1) mock_bsblan.thermostat.reset_mock() # Test setting HVAC mode - should convert to integer (3=heat) @@ -360,7 +362,7 @@ async def test_async_set_data( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, blocking=True, ) - mock_bsblan.thermostat.assert_called_once_with(hvac_mode=3) # 3 = heat + mock_bsblan.thermostat.assert_called_once_with(hvac_mode=3, circuit=1) # 3 = heat mock_bsblan.thermostat.reset_mock() # Patch HVAC mode to AUTO (integer 1) @@ -375,7 +377,9 @@ async def test_async_set_data( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_ECO}, blocking=True, ) - mock_bsblan.thermostat.assert_called_once_with(hvac_mode=2) # 2 = eco/reduced + mock_bsblan.thermostat.assert_called_once_with( + hvac_mode=2, circuit=1 + ) # 2 = eco/reduced mock_bsblan.thermostat.reset_mock() # Test setting preset mode to NONE - should use integer 1 (auto) @@ -385,7 +389,7 @@ async def test_async_set_data( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_NONE}, blocking=True, ) - mock_bsblan.thermostat.assert_called_once_with(hvac_mode=1) # 1 = auto + mock_bsblan.thermostat.assert_called_once_with(hvac_mode=1, circuit=1) # 1 = auto mock_bsblan.thermostat.reset_mock() # Test error handling @@ -398,3 +402,22 @@ async def test_async_set_data( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 20}, blocking=True, ) + + +async def test_dual_circuit_climate_entities( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry_dual_circuit: MockConfigEntry, +) -> None: + """Test that dual-circuit config creates two climate entities with correct IDs.""" + await setup_with_selected_platforms( + hass, mock_config_entry_dual_circuit, [Platform.CLIMATE] + ) + + # Circuit 1 entity should exist + state1 = hass.states.get("climate.heating_circuit_1") + assert state1 is not None + + # Circuit 2 entity should exist + state2 = hass.states.get("climate.heating_circuit_2") + assert state2 is not None diff --git a/tests/components/bsblan/test_config_flow.py b/tests/components/bsblan/test_config_flow.py index 8df61413b9a44..feda9e9b40877 100644 --- a/tests/components/bsblan/test_config_flow.py +++ b/tests/components/bsblan/test_config_flow.py @@ -7,7 +7,11 @@ import pytest import voluptuous as vol -from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN +from homeassistant.components.bsblan.const import ( + CONF_HEATING_CIRCUITS, + CONF_PASSKEY, + DOMAIN, +) from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -144,13 +148,14 @@ async def test_full_user_flow_implementation( _assert_create_entry_result( result, - format_mac("00:80:41:19:69:90"), + "BSB-LAN", { CONF_HOST: "127.0.0.1", CONF_PORT: 80, CONF_PASSKEY: "1234", CONF_USERNAME: "admin", CONF_PASSWORD: "admin1234", + CONF_HEATING_CIRCUITS: [1], }, format_mac("00:80:41:19:69:90"), ) @@ -165,6 +170,49 @@ async def test_show_user_form(hass: HomeAssistant) -> None: _assert_form_result(result, "user") +@pytest.mark.parametrize( + "side_effect", + [BSBLANError, TimeoutError], +) +async def test_circuit_discovery_failure_falls_back_to_default( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_setup_entry: AsyncMock, + side_effect: type[Exception], +) -> None: + """Test that circuit discovery failure falls back to single circuit.""" + mock_bsblan.initialize.side_effect = side_effect + + result = await _init_user_flow(hass) + _assert_form_result(result, "user") + + result = await _configure_flow( + hass, + result["flow_id"], + { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_create_entry_result( + result, + "BSB-LAN", + { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + CONF_HEATING_CIRCUITS: [1], + }, + format_mac("00:80:41:19:69:90"), + ) + + async def test_connection_error( hass: HomeAssistant, mock_bsblan: MagicMock, @@ -319,13 +367,14 @@ async def test_zeroconf_discovery( _assert_create_entry_result( result, - format_mac("00:80:41:19:69:90"), + "BSB-LAN", { CONF_HOST: "10.0.2.60", CONF_PORT: 80, CONF_PASSKEY: "1234", CONF_USERNAME: "admin", CONF_PASSWORD: "admin1234", + CONF_HEATING_CIRCUITS: [1], }, format_mac("00:80:41:19:69:90"), ) @@ -386,13 +435,14 @@ async def test_zeroconf_discovery_no_mac_requires_auth( _assert_create_entry_result( result, - "00:80:41:19:69:90", # MAC from fixture file + "BSB-LAN", { CONF_HOST: "10.0.2.60", CONF_PORT: 80, CONF_PASSKEY: None, CONF_USERNAME: "admin", CONF_PASSWORD: "secret", + CONF_HEATING_CIRCUITS: [1], }, "00:80:41:19:69:90", ) @@ -418,13 +468,14 @@ async def test_zeroconf_discovery_no_mac_no_auth_required( _assert_create_entry_result( result, - "00:80:41:19:69:90", # MAC from fixture file + "BSB-LAN", { CONF_HOST: "10.0.2.60", CONF_PORT: 80, CONF_PASSKEY: None, CONF_USERNAME: None, CONF_PASSWORD: None, + CONF_HEATING_CIRCUITS: [1], }, "00:80:41:19:69:90", ) @@ -562,13 +613,14 @@ async def test_zeroconf_discovery_connection_error_recovery( _assert_create_entry_result( result, - format_mac("00:80:41:19:69:90"), + "BSB-LAN", { CONF_HOST: "10.0.2.60", CONF_PORT: 80, CONF_PASSKEY: "1234", CONF_USERNAME: "admin", CONF_PASSWORD: "admin1234", + CONF_HEATING_CIRCUITS: [1], }, format_mac("00:80:41:19:69:90"), ) @@ -617,13 +669,14 @@ async def test_connection_error_recovery( _assert_create_entry_result( result, - format_mac("00:80:41:19:69:90"), + "BSB-LAN", { CONF_HOST: "127.0.0.1", CONF_PORT: 80, CONF_PASSKEY: "1234", CONF_USERNAME: "admin", CONF_PASSWORD: "admin1234", + CONF_HEATING_CIRCUITS: [1], }, format_mac("00:80:41:19:69:90"), ) @@ -1094,6 +1147,7 @@ async def test_reconfigure_flow_success( assert mock_config_entry.data[CONF_PASSKEY] == "new_passkey" assert mock_config_entry.data[CONF_USERNAME] == "new_admin" assert mock_config_entry.data[CONF_PASSWORD] == "new_password" + assert mock_config_entry.data[CONF_HEATING_CIRCUITS] == [1] @pytest.mark.parametrize( @@ -1107,7 +1161,7 @@ async def test_reconfigure_flow_error_recovery( hass: HomeAssistant, mock_bsblan: MagicMock, mock_config_entry: MockConfigEntry, - side_effect: Exception, + side_effect: type[Exception], error: str, ) -> None: """Test reconfigure flow can recover from errors.""" @@ -1155,6 +1209,7 @@ async def test_reconfigure_flow_error_recovery( assert mock_config_entry.data[CONF_PASSKEY] == "new_passkey" assert mock_config_entry.data[CONF_USERNAME] == "new_admin" assert mock_config_entry.data[CONF_PASSWORD] == "new_password" + assert mock_config_entry.data[CONF_HEATING_CIRCUITS] == [1] async def test_reconfigure_flow_unique_id_mismatch( diff --git a/tests/components/bsblan/test_diagnostics.py b/tests/components/bsblan/test_diagnostics.py index 7182cf3268594..71463051e2588 100644 --- a/tests/components/bsblan/test_diagnostics.py +++ b/tests/components/bsblan/test_diagnostics.py @@ -50,4 +50,4 @@ async def test_diagnostics_without_static_values( assert "info" in diagnostics_data assert "device" in diagnostics_data assert "fast_coordinator_data" in diagnostics_data - assert diagnostics_data["static"] is None + assert diagnostics_data["static"] == {"1": None} diff --git a/tests/components/bsblan/test_init.py b/tests/components/bsblan/test_init.py index bc847031a02b2..5496cf09fd00a 100644 --- a/tests/components/bsblan/test_init.py +++ b/tests/components/bsblan/test_init.py @@ -1,16 +1,21 @@ """Tests for the BSBLan integration.""" +from datetime import timedelta from unittest.mock import MagicMock from bsblan import BSBLANAuthError, BSBLANConnectionError, BSBLANError from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN +from homeassistant.components.bsblan.const import ( + CONF_HEATING_CIRCUITS, + CONF_PASSKEY, + DOMAIN, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry, async_fire_time_changed @@ -134,7 +139,7 @@ async def test_config_entry_setup_errors( assert mock_config_entry.state is expected_state if assert_static_fallback: - assert mock_config_entry.runtime_data.static is None + assert mock_config_entry.runtime_data.static == {1: None} async def test_coordinator_dhw_config_update_error( @@ -208,6 +213,7 @@ async def test_coordinator_fast_no_dhw_support( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_bsblan: MagicMock, + entity_registry: er.EntityRegistry, ) -> None: """Test fast coordinator when device does not support DHW.""" mock_bsblan.hot_water_state.side_effect = BSBLANError( @@ -224,8 +230,46 @@ async def test_coordinator_fast_no_dhw_support( # DHW data should be None in the fast coordinator assert mock_config_entry.runtime_data.fast_coordinator.data.dhw is None - # Water heater entity should not be created - assert hass.states.get("water_heater.bsb_lan") is None + # No water heater entities should be registered for this config entry + water_heater_entities = [ + entry + for entry in er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + if entry.domain == "water_heater" + ] + assert not water_heater_entities + + +async def test_coordinator_fast_dhw_fails_on_refresh_preserves_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_bsblan: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test fast coordinator preserves last DHW state when DHW fails on refresh.""" + mock_config_entry.add_to_hass(hass) + 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 + + # DHW should be available initially + coordinator = mock_config_entry.runtime_data.fast_coordinator + initial_dhw = coordinator.data.dhw + assert initial_dhw is not None + + # Now make DHW fail on the next refresh + mock_bsblan.hot_water_state.side_effect = BSBLANError( + "None of the requested parameters are valid for this section" + ) + + freezer.tick(timedelta(seconds=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Last known DHW state should be preserved + assert coordinator.data.dhw is initial_dhw async def test_coordinator_slow_no_dhw_support( @@ -295,3 +339,123 @@ async def test_configuration_url_non_default_port( ) assert device is not None assert device.configuration_url == "http://192.168.1.100:8080" + + +def _legacy_entry_data() -> dict: + """Return config entry data as stored before CONF_HEATING_CIRCUITS existed.""" + return { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + } + + +async def test_migrate_entry_discovers_circuits( + hass: HomeAssistant, + mock_bsblan: MagicMock, +) -> None: + """Test migration from 1.1 to 1.2 discovers available circuits.""" + mock_bsblan.get_available_circuits.return_value = [1, 2] + + entry = MockConfigEntry( + title="BSBLAN Setup", + domain=DOMAIN, + data=_legacy_entry_data(), + unique_id="00:80:41:19:69:90", + version=1, + minor_version=1, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert entry.version == 1 + assert entry.minor_version == 2 + assert entry.data[CONF_HEATING_CIRCUITS] == [1, 2] + + +async def test_migrate_entry_discovery_failure_falls_back( + hass: HomeAssistant, + mock_bsblan: MagicMock, +) -> None: + """Test migration falls back to [1] when circuit discovery fails.""" + mock_bsblan.get_available_circuits.side_effect = BSBLANError("boom") + + entry = MockConfigEntry( + title="BSBLAN Setup", + domain=DOMAIN, + data=_legacy_entry_data(), + unique_id="00:80:41:19:69:90", + version=1, + minor_version=1, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert entry.version == 1 + assert entry.minor_version == 2 + assert entry.data[CONF_HEATING_CIRCUITS] == [1] + + +async def test_migrate_entry_discovery_timeout_falls_back( + hass: HomeAssistant, + mock_bsblan: MagicMock, +) -> None: + """Test migration falls back to [1] when circuit discovery times out.""" + mock_bsblan.get_available_circuits.side_effect = TimeoutError + + entry = MockConfigEntry( + title="BSBLAN Setup", + domain=DOMAIN, + data=_legacy_entry_data(), + unique_id="00:80:41:19:69:90", + version=1, + minor_version=1, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert entry.minor_version == 2 + assert entry.data[CONF_HEATING_CIRCUITS] == [1] + + +async def test_migrate_entry_future_version_aborts( + hass: HomeAssistant, + mock_bsblan: MagicMock, +) -> None: + """Test migration refuses to downgrade from a future major version.""" + entry = MockConfigEntry( + title="BSBLAN Setup", + domain=DOMAIN, + data={**_legacy_entry_data(), CONF_HEATING_CIRCUITS: [1]}, + unique_id="00:80:41:19:69:90", + version=2, + minor_version=1, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.MIGRATION_ERROR + + +async def test_migrate_entry_already_current( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_bsblan: MagicMock, +) -> None: + """Test that an up-to-date entry is loaded without re-running discovery.""" + mock_config_entry.add_to_hass(hass) + 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 mock_bsblan.get_available_circuits.call_count == 0 + assert mock_config_entry.data[CONF_HEATING_CIRCUITS] == [1] diff --git a/tests/components/bsblan/test_water_heater.py b/tests/components/bsblan/test_water_heater.py index 1427552416ddb..dac87221dbc99 100644 --- a/tests/components/bsblan/test_water_heater.py +++ b/tests/components/bsblan/test_water_heater.py @@ -28,7 +28,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform -ENTITY_ID = "water_heater.bsb_lan" +ENTITY_ID = "water_heater.water_heater" @pytest.fixture @@ -181,7 +181,7 @@ async def test_set_invalid_operation_mode( with pytest.raises( HomeAssistantError, - match=r"Operation mode invalid_mode is not valid for water_heater\.bsb_lan\. Valid operation modes are: off, performance, eco", + match=r"Operation mode invalid_mode is not valid for water_heater\.water_heater\. Valid operation modes are: off, performance, eco", ): await hass.services.async_call( domain=WATER_HEATER_DOMAIN, diff --git a/tests/components/esphome/test_radio_frequency.py b/tests/components/esphome/test_radio_frequency.py new file mode 100644 index 0000000000000..b6c4b82953bce --- /dev/null +++ b/tests/components/esphome/test_radio_frequency.py @@ -0,0 +1,208 @@ +"""Test ESPHome radio frequency platform.""" + +from aioesphomeapi import ( + APIClient, + APIConnectionError, + RadioFrequencyCapability, + RadioFrequencyInfo, + RadioFrequencyModulation, +) +import pytest +from rf_protocols import ModulationType, OOKCommand + +from homeassistant.components import radio_frequency +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .conftest import MockESPHomeDevice, MockESPHomeDeviceType + +ENTITY_ID = "radio_frequency.test_rf" + + +async def _mock_rf_device( + mock_esphome_device: MockESPHomeDeviceType, + mock_client: APIClient, + capabilities: RadioFrequencyCapability = RadioFrequencyCapability.TRANSMITTER, + frequency_min: int = 433_000_000, + frequency_max: int = 434_000_000, + supported_modulations: int = 1, +) -> MockESPHomeDevice: + entity_info = [ + RadioFrequencyInfo( + object_id="rf", + key=1, + name="RF", + capabilities=capabilities, + frequency_min=frequency_min, + frequency_max=frequency_max, + supported_modulations=supported_modulations, + ) + ] + return await mock_esphome_device( + mock_client=mock_client, entity_info=entity_info, states=[] + ) + + +@pytest.mark.parametrize( + ("capabilities", "entity_created"), + [ + (RadioFrequencyCapability.TRANSMITTER, True), + (RadioFrequencyCapability.RECEIVER, False), + ( + RadioFrequencyCapability.TRANSMITTER | RadioFrequencyCapability.RECEIVER, + True, + ), + (RadioFrequencyCapability(0), False), + ], +) +async def test_radio_frequency_entity_transmitter( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + capabilities: RadioFrequencyCapability, + entity_created: bool, +) -> None: + """Test radio frequency entity with transmitter capability is created.""" + await _mock_rf_device(mock_esphome_device, mock_client, capabilities) + + state = hass.states.get(ENTITY_ID) + assert (state is not None) == entity_created + + +async def test_radio_frequency_multiple_entities_mixed_capabilities( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test multiple radio frequency entities with mixed capabilities.""" + entity_info = [ + RadioFrequencyInfo( + object_id="rf_transmitter", + key=1, + name="RF Transmitter", + capabilities=RadioFrequencyCapability.TRANSMITTER, + ), + RadioFrequencyInfo( + object_id="rf_receiver", + key=2, + name="RF Receiver", + capabilities=RadioFrequencyCapability.RECEIVER, + ), + RadioFrequencyInfo( + object_id="rf_transceiver", + key=3, + name="RF Transceiver", + capabilities=( + RadioFrequencyCapability.TRANSMITTER | RadioFrequencyCapability.RECEIVER + ), + ), + ] + await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + states=[], + ) + + # Only transmitter and transceiver should be created + assert hass.states.get("radio_frequency.test_rf_transmitter") is not None + assert hass.states.get("radio_frequency.test_rf_receiver") is None + assert hass.states.get("radio_frequency.test_rf_transceiver") is not None + + +async def test_radio_frequency_send_command_success( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test sending RF command successfully.""" + await _mock_rf_device(mock_esphome_device, mock_client) + + command = OOKCommand( + frequency=433_920_000, + timings=[350, -1050, 350, -350], + ) + await radio_frequency.async_send_command(hass, ENTITY_ID, command) + + mock_client.radio_frequency_transmit_raw_timings.assert_called_once() + call_args = mock_client.radio_frequency_transmit_raw_timings.call_args + assert call_args[0][0] == 1 # key + assert call_args[1]["frequency"] == 433_920_000 + assert call_args[1]["modulation"] == RadioFrequencyModulation.OOK + assert call_args[1]["repeat_count"] == 1 + assert call_args[1]["device_id"] == 0 + assert call_args[1]["timings"] == [350, -1050, 350, -350] + + +async def test_radio_frequency_send_command_failure( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test sending RF command with APIConnectionError raises HomeAssistantError.""" + await _mock_rf_device(mock_esphome_device, mock_client) + + mock_client.radio_frequency_transmit_raw_timings.side_effect = APIConnectionError( + "Connection lost" + ) + + command = OOKCommand( + frequency=433_920_000, + timings=[350, -1050], + ) + + with pytest.raises(HomeAssistantError) as exc_info: + await radio_frequency.async_send_command(hass, ENTITY_ID, command) + assert exc_info.value.translation_domain == "esphome" + assert exc_info.value.translation_key == "error_communicating_with_device" + + +async def test_radio_frequency_entity_availability( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test radio frequency entity becomes available after device reconnects.""" + mock_device = await _mock_rf_device(mock_esphome_device, mock_client) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + await mock_device.mock_disconnect(False) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + await mock_device.mock_connect() + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + +async def test_radio_frequency_supported_frequency_ranges( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test supported frequency ranges are exposed from device info.""" + await _mock_rf_device( + mock_esphome_device, + mock_client, + frequency_min=433_000_000, + frequency_max=434_000_000, + ) + + transmitters = radio_frequency.async_get_transmitters( + hass, 433_920_000, ModulationType.OOK + ) + assert len(transmitters) == 1 + + transmitters = radio_frequency.async_get_transmitters( + hass, 868_000_000, ModulationType.OOK + ) + assert len(transmitters) == 0 diff --git a/tests/components/fritz/snapshots/test_sensor.ambr b/tests/components/fritz/snapshots/test_sensor.ambr index d820dda43ee9e..48ed12dec8fec 100644 --- a/tests/components/fritz/snapshots/test_sensor.ambr +++ b/tests/components/fritz/snapshots/test_sensor.ambr @@ -24,7 +24,7 @@ 'object_id_base': 'Connection uptime', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Connection uptime', 'platform': 'fritz', @@ -39,7 +39,7 @@ # name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_connection_uptime-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', + 'device_class': 'uptime', 'friendly_name': 'Mock Title Connection uptime', }), 'context': , @@ -349,7 +349,7 @@ 'object_id_base': 'Last restart', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Last restart', 'platform': 'fritz', @@ -364,7 +364,7 @@ # name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_last_restart-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', + 'device_class': 'uptime', 'friendly_name': 'Mock Title Last restart', }), 'context': , @@ -882,7 +882,7 @@ 'object_id_base': 'Connection uptime', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Connection uptime', 'platform': 'fritz', @@ -897,7 +897,7 @@ # name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_connection_uptime-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', + 'device_class': 'uptime', 'friendly_name': 'Mock Title Connection uptime', }), 'context': , @@ -1207,7 +1207,7 @@ 'object_id_base': 'Last restart', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Last restart', 'platform': 'fritz', @@ -1222,7 +1222,7 @@ # name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_last_restart-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', + 'device_class': 'uptime', 'friendly_name': 'Mock Title Last restart', }), 'context': , @@ -1740,7 +1740,7 @@ 'object_id_base': 'Connection uptime', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Connection uptime', 'platform': 'fritz', @@ -1755,7 +1755,7 @@ # name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_connection_uptime-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', + 'device_class': 'uptime', 'friendly_name': 'Mock Title Connection uptime', }), 'context': , @@ -2065,7 +2065,7 @@ 'object_id_base': 'Last restart', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Last restart', 'platform': 'fritz', @@ -2080,7 +2080,7 @@ # name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_last_restart-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', + 'device_class': 'uptime', 'friendly_name': 'Mock Title Last restart', }), 'context': , @@ -2598,7 +2598,7 @@ 'object_id_base': 'Connection uptime', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Connection uptime', 'platform': 'fritz', @@ -2613,7 +2613,7 @@ # name: test_sensor_setup[sensor.mock_title_connection_uptime-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', + 'device_class': 'uptime', 'friendly_name': 'Mock Title Connection uptime', }), 'context': , @@ -2981,7 +2981,7 @@ 'object_id_base': 'Last restart', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Last restart', 'platform': 'fritz', @@ -2996,7 +2996,7 @@ # name: test_sensor_setup[sensor.mock_title_last_restart-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', + 'device_class': 'uptime', 'friendly_name': 'Mock Title Last restart', }), 'context': , diff --git a/tests/components/fritz/test_sensor.py b/tests/components/fritz/test_sensor.py index d00327994d633..94cb74f63a0e9 100644 --- a/tests/components/fritz/test_sensor.py +++ b/tests/components/fritz/test_sensor.py @@ -11,7 +11,7 @@ from requests.exceptions import RequestException from syrupy.assertion import SnapshotAssertion -from homeassistant.components.fritz.const import DOMAIN, SCAN_INTERVAL, UPTIME_DEVIATION +from homeassistant.components.fritz.const import DOMAIN, SCAN_INTERVAL from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant @@ -95,13 +95,13 @@ async def test_sensor_uptime_spike( assert (state := hass.states.get(entity_id)) assert state.state == "2026-01-16T06:00:21+00:00" - # Simulate uptime spike by setting uptime to a value between - # the previous one and a delta smaller than UPTIME_DEVIATION + # Simulate uptime spike by setting uptime to a value that shifts + # the resulting timestamp only by 1 second. base_uptime = MOCK_FB_SERVICES["DeviceInfo1"]["GetInfo"]["NewUpTime"] update_uptime = { "DeviceInfo1": { "GetInfo": { - "NewUpTime": base_uptime + SCAN_INTERVAL - UPTIME_DEVIATION + 1, + "NewUpTime": base_uptime + SCAN_INTERVAL + 1, }, }, } diff --git a/tests/components/homeassistant/triggers/test_time.py b/tests/components/homeassistant/triggers/test_time.py index dc9fb1d34c27f..79f07c5285d3b 100644 --- a/tests/components/homeassistant/triggers/test_time.py +++ b/tests/components/homeassistant/triggers/test_time.py @@ -521,11 +521,16 @@ async def test_untrack_time_change(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("at_sensor"), ["sensor.next_alarm", "{{ 'sensor.next_alarm' }}"] ) +@pytest.mark.parametrize( + "device_class", + [SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME], +) async def test_if_fires_using_at_sensor( hass: HomeAssistant, freezer: FrozenDateTimeFactory, service_calls: list[ServiceCall], at_sensor: str, + device_class: SensorDeviceClass, ) -> None: """Test for firing at sensor time.""" now = dt_util.now() @@ -535,7 +540,7 @@ async def test_if_fires_using_at_sensor( hass.states.async_set( "sensor.next_alarm", trigger_dt.isoformat(), - {ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP}, + {ATTR_DEVICE_CLASS: device_class}, ) time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) @@ -572,7 +577,7 @@ async def test_if_fires_using_at_sensor( hass.states.async_set( "sensor.next_alarm", trigger_dt.isoformat(), - {ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP}, + {ATTR_DEVICE_CLASS: device_class}, ) await hass.async_block_till_done() @@ -589,13 +594,13 @@ async def test_if_fires_using_at_sensor( hass.states.async_set( "sensor.next_alarm", trigger_dt.isoformat(), - {ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP}, + {ATTR_DEVICE_CLASS: device_class}, ) await hass.async_block_till_done() hass.states.async_set( "sensor.next_alarm", broken, - {ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP}, + {ATTR_DEVICE_CLASS: device_class}, ) await hass.async_block_till_done() @@ -609,7 +614,7 @@ async def test_if_fires_using_at_sensor( hass.states.async_set( "sensor.next_alarm", trigger_dt.isoformat(), - {ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP}, + {ATTR_DEVICE_CLASS: device_class}, ) await hass.async_block_till_done() hass.states.async_set( @@ -633,12 +638,17 @@ async def test_if_fires_using_at_sensor( ({"minutes": 5}, timedelta(minutes=5)), ], ) +@pytest.mark.parametrize( + "device_class", + [SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME], +) async def test_if_fires_using_at_sensor_with_offset( hass: HomeAssistant, service_calls: list[ServiceCall], freezer: FrozenDateTimeFactory, offset: str | dict[str, int], delta: timedelta, + device_class: SensorDeviceClass, ) -> None: """Test for firing at sensor time.""" now = dt_util.now() @@ -649,7 +659,7 @@ async def test_if_fires_using_at_sensor_with_offset( hass.states.async_set( "sensor.next_alarm", start_dt.isoformat(), - {ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP}, + {ATTR_DEVICE_CLASS: device_class}, ) time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) @@ -693,7 +703,7 @@ async def test_if_fires_using_at_sensor_with_offset( hass.states.async_set( "sensor.next_alarm", start_dt.isoformat(), - {ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP}, + {ATTR_DEVICE_CLASS: device_class}, ) await hass.async_block_till_done() diff --git a/tests/components/honeywell_string_lights/__init__.py b/tests/components/honeywell_string_lights/__init__.py new file mode 100644 index 0000000000000..948d9ef3ec3e0 --- /dev/null +++ b/tests/components/honeywell_string_lights/__init__.py @@ -0,0 +1 @@ +"""Tests for the Honeywell String Lights integration.""" diff --git a/tests/components/honeywell_string_lights/conftest.py b/tests/components/honeywell_string_lights/conftest.py new file mode 100644 index 0000000000000..e164c7f4a0cde --- /dev/null +++ b/tests/components/honeywell_string_lights/conftest.py @@ -0,0 +1,48 @@ +"""Common fixtures for the Honeywell String Lights tests.""" + +from __future__ import annotations + +import pytest + +from homeassistant.components.honeywell_string_lights.const import ( + CONF_TRANSMITTER, + DOMAIN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry +from tests.components.radio_frequency.conftest import ( + MockRadioFrequencyEntity, + init_integration, # noqa: F401 + mock_rf_entity, # noqa: F401 +) + +TRANSMITTER_ENTITY_ID = "radio_frequency.test_rf_transmitter" + + +@pytest.fixture +def mock_config_entry( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, # noqa: F811 +) -> MockConfigEntry: + """Return a mock config entry for Honeywell String Lights.""" + entity_registry = er.async_get(hass) + entity_entry = entity_registry.async_get(TRANSMITTER_ENTITY_ID) + return MockConfigEntry( + domain=DOMAIN, + title="Honeywell String Lights", + data={CONF_TRANSMITTER: entity_entry.id}, + unique_id=entity_entry.id, + ) + + +@pytest.fixture +async def init_string_lights( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> MockConfigEntry: + """Set up the Honeywell String Lights integration.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_config_entry diff --git a/tests/components/honeywell_string_lights/test_config_flow.py b/tests/components/honeywell_string_lights/test_config_flow.py new file mode 100644 index 0000000000000..3826e2b50b748 --- /dev/null +++ b/tests/components/honeywell_string_lights/test_config_flow.py @@ -0,0 +1,90 @@ +"""Test the Honeywell String Lights config flow.""" + +from __future__ import annotations + +from homeassistant.components.honeywell_string_lights.const import ( + CONF_TRANSMITTER, + DOMAIN, +) +from homeassistant.components.radio_frequency import DATA_COMPONENT, DOMAIN as RF_DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from .conftest import TRANSMITTER_ENTITY_ID + +from tests.common import MockConfigEntry +from tests.components.radio_frequency.conftest import MockRadioFrequencyEntity + + +async def test_user_flow( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + entity_registry: er.EntityRegistry, +) -> None: + """Test the user config flow creates an entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_TRANSMITTER: TRANSMITTER_ENTITY_ID}, + ) + + entity_entry = entity_registry.async_get(TRANSMITTER_ENTITY_ID) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Honeywell String Lights" + assert result["data"] == {CONF_TRANSMITTER: entity_entry.id} + assert result["result"].unique_id == entity_entry.id + + +async def test_unique_id_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test aborting when the same transmitter is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_TRANSMITTER: TRANSMITTER_ENTITY_ID}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_no_transmitters(hass: HomeAssistant) -> None: + """Test the flow aborts when no RF transmitters are registered at all.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_transmitters" + + +async def test_no_compatible_transmitters(hass: HomeAssistant) -> None: + """Test aborting when transmitters exist but none support 433.92 MHz OOK.""" + assert await async_setup_component(hass, RF_DOMAIN, {}) + await hass.async_block_till_done() + incompatible = MockRadioFrequencyEntity( + "incompatible", frequency_ranges=[(868_000_000, 869_000_000)] + ) + await hass.data[DATA_COMPONENT].async_add_entities([incompatible]) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_compatible_transmitters" diff --git a/tests/components/honeywell_string_lights/test_light.py b/tests/components/honeywell_string_lights/test_light.py new file mode 100644 index 0000000000000..f2955f2db2e5d --- /dev/null +++ b/tests/components/honeywell_string_lights/test_light.py @@ -0,0 +1,102 @@ +"""Tests for the Honeywell String Lights light platform.""" + +from __future__ import annotations + +from homeassistant.components.honeywell_string_lights.light import COMMANDS +from homeassistant.components.light import ( + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import Context, HomeAssistant, State + +from tests.common import MockConfigEntry, mock_restore_cache +from tests.components.radio_frequency.conftest import MockRadioFrequencyEntity + +ENTITY_ID = "light.honeywell_string_lights" + + +async def test_turn_on_off_sends_commands( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + init_string_lights: MockConfigEntry, +) -> None: + """Test turning the light on and off sends the correct RF commands.""" + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + assert state.attributes[ATTR_ASSUMED_STATE] is True + + context = Context() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + context=context, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + assert state.context is context + assert len(mock_rf_entity.send_command_calls) == 1 + command = mock_rf_entity.send_command_calls[0] + assert command.command is COMMANDS.load_command("turn_on") + assert command.context is context + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + context=context, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + assert state.context is context + assert len(mock_rf_entity.send_command_calls) == 2 + command = mock_rf_entity.send_command_calls[1] + assert command.command is COMMANDS.load_command("turn_off") + assert command.context is context + + +async def test_restore_state( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the light restores its previous on state.""" + mock_restore_cache(hass, [State(ENTITY_ID, STATE_ON)]) + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + + +async def test_unload_entry( + hass: HomeAssistant, init_string_lights: MockConfigEntry +) -> None: + """Test unloading the config entry removes the entity.""" + assert hass.states.get(ENTITY_ID) is not None + + assert await hass.config_entries.async_unload(init_string_lights.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/hr_energy_qube/conftest.py b/tests/components/hr_energy_qube/conftest.py index 76a86fbe5d055..26ec2bfc25521 100644 --- a/tests/components/hr_energy_qube/conftest.py +++ b/tests/components/hr_energy_qube/conftest.py @@ -64,6 +64,56 @@ def mock_qube_client() -> Generator[MagicMock]: state.setpoint_room_cool_night = 23.0 state.status_code = 1 + # Binary sensors - Outputs + state.dout_srcpmp_val = True + state.dout_usrpmp_val = True + state.dout_fourwayvlv_val = False + state.dout_cooling_val = False + state.dout_threewayvlv_val = False + state.dout_bufferpmp_val = False + state.dout_heaterstep1_val = False + state.dout_heaterstep2_val = False + state.dout_heaterstep3_val = False + + # Binary sensors - System status + state.keybonoff = True + state.daynightmode = True + + # Binary sensors - Alarms + state.al_maxtime_antileg_active = False + state.al_maxtime_dhw_active = False + state.al_dewpoint_active = False + state.al_underfloorsafety_active = False + state.alrm_flw = False + state.usralrms = False + state.coolingalrms = False + state.heatingalrms = False + state.alarmmng_al_workinghour = False + state.srsalrm = False + state.glbal = False + state.alarmmng_al_pwrplus = False + + # Binary sensors - Sensor/controller status + state.roomprb_en = True + state.plantprb_en = False + state.bufferprb_en = False + state.en_dhwpid = True + + # Binary sensors - Demand signals + state.plantdemand = False + state.id_demand = False + state.thermostatdemand = True + + # Binary sensors - Digital inputs + state.id_summerwinter = False + state.dewpoint = False + state.boostersecurity = False + state.srcflw = True + state.req_antileg_1 = False + + # Binary sensors - Energy + state.surplus_pv = False + client.get_all_data = AsyncMock(return_value=state) yield client diff --git a/tests/components/hr_energy_qube/snapshots/test_binary_sensor.ambr b/tests/components/hr_energy_qube/snapshots/test_binary_sensor.ambr new file mode 100644 index 0000000000000..c8a9db8bababa --- /dev/null +++ b/tests/components/hr_energy_qube/snapshots/test_binary_sensor.ambr @@ -0,0 +1,1813 @@ +# serializer version: 1 +# name: test_entities[binary_sensor.qube_heat_pump_anti_legionella-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.qube_heat_pump_anti_legionella', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Anti-legionella', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Anti-legionella', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'anti_legionella', + 'unique_id': '01JQUBEHEATPUMP00000000000-anti_legionella', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_anti_legionella-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Qube heat pump Anti-legionella', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_anti_legionella', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_anti_legionella_timeout_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.qube_heat_pump_anti_legionella_timeout_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Anti-legionella timeout alarm', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Anti-legionella timeout alarm', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_antilegionella_timeout', + 'unique_id': '01JQUBEHEATPUMP00000000000-alarm_antilegionella_timeout', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_anti_legionella_timeout_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Qube heat pump Anti-legionella timeout alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_anti_legionella_timeout_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_booster_security-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.qube_heat_pump_booster_security', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Booster security', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Booster security', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'booster_security', + 'unique_id': '01JQUBEHEATPUMP00000000000-booster_security', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_booster_security-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Qube heat pump Booster security', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_booster_security', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_buffer_pump-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.qube_heat_pump_buffer_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Buffer pump', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Buffer pump', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'buffer_pump', + 'unique_id': '01JQUBEHEATPUMP00000000000-buffer_pump', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_buffer_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Qube heat pump Buffer pump', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_buffer_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_buffer_sensor_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.qube_heat_pump_buffer_sensor_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Buffer sensor enabled', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Buffer sensor enabled', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'buffer_sensor_enabled', + 'unique_id': '01JQUBEHEATPUMP00000000000-buffer_sensor_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_buffer_sensor_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Qube heat pump Buffer sensor enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_buffer_sensor_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_central_heating_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.qube_heat_pump_central_heating_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Central heating alarm', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Central heating alarm', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_central_heating', + 'unique_id': '01JQUBEHEATPUMP00000000000-alarm_central_heating', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_central_heating_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Qube heat pump Central heating alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_central_heating_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_compressor_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.qube_heat_pump_compressor_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Compressor alarm', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Compressor alarm', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_compressor', + 'unique_id': '01JQUBEHEATPUMP00000000000-alarm_compressor', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_compressor_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Qube heat pump Compressor alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_compressor_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_cooling_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.qube_heat_pump_cooling_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cooling alarm', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cooling alarm', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_cooling', + 'unique_id': '01JQUBEHEATPUMP00000000000-alarm_cooling', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_cooling_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Qube heat pump Cooling alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_cooling_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_cooling_output-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.qube_heat_pump_cooling_output', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cooling output', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cooling output', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooling_output', + 'unique_id': '01JQUBEHEATPUMP00000000000-cooling_output', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_cooling_output-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Qube heat pump Cooling output', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_cooling_output', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_day_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.qube_heat_pump_day_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Day mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Day mode', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'day_mode', + 'unique_id': '01JQUBEHEATPUMP00000000000-day_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_day_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Qube heat pump Day mode', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_day_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_dewpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.qube_heat_pump_dewpoint', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Dewpoint', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dewpoint', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dewpoint', + 'unique_id': '01JQUBEHEATPUMP00000000000-dewpoint', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_dewpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Qube heat pump Dewpoint', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_dewpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_dewpoint_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.qube_heat_pump_dewpoint_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Dewpoint alarm', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dewpoint alarm', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_dewpoint', + 'unique_id': '01JQUBEHEATPUMP00000000000-alarm_dewpoint', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_dewpoint_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Qube heat pump Dewpoint alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_dewpoint_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_dhw_controller_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.qube_heat_pump_dhw_controller_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'DHW controller enabled', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'DHW controller enabled', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dhw_controller_enabled', + 'unique_id': '01JQUBEHEATPUMP00000000000-dhw_controller_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_dhw_controller_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Qube heat pump DHW controller enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_dhw_controller_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_dhw_timeout_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.qube_heat_pump_dhw_timeout_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'DHW timeout alarm', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHW timeout alarm', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_dhw_timeout', + 'unique_id': '01JQUBEHEATPUMP00000000000-alarm_dhw_timeout', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_dhw_timeout_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Qube heat pump DHW timeout alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_dhw_timeout_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_external_demand-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.qube_heat_pump_external_demand', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'External demand', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'External demand', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'external_demand', + 'unique_id': '01JQUBEHEATPUMP00000000000-external_demand', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_external_demand-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Qube heat pump External demand', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_external_demand', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_flow_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.qube_heat_pump_flow_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Flow alarm', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Flow alarm', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_flow', + 'unique_id': '01JQUBEHEATPUMP00000000000-alarm_flow', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_flow_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Qube heat pump Flow alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_flow_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_four_way_valve-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.qube_heat_pump_four_way_valve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Four-way valve', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Four-way valve', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'four_way_valve', + 'unique_id': '01JQUBEHEATPUMP00000000000-four_way_valve', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_four_way_valve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Qube heat pump Four-way valve', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_four_way_valve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_global_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.qube_heat_pump_global_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Global alarm', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Global alarm', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_global', + 'unique_id': '01JQUBEHEATPUMP00000000000-alarm_global', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_global_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Qube heat pump Global alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_global_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_heater_step_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.qube_heat_pump_heater_step_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Heater step 1', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heater step 1', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heater_step_1', + 'unique_id': '01JQUBEHEATPUMP00000000000-heater_step_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_heater_step_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Qube heat pump Heater step 1', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_heater_step_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_heater_step_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.qube_heat_pump_heater_step_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Heater step 2', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heater step 2', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heater_step_2', + 'unique_id': '01JQUBEHEATPUMP00000000000-heater_step_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_heater_step_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Qube heat pump Heater step 2', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_heater_step_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_heater_step_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.qube_heat_pump_heater_step_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Heater step 3', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heater step 3', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heater_step_3', + 'unique_id': '01JQUBEHEATPUMP00000000000-heater_step_3', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_heater_step_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Qube heat pump Heater step 3', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_heater_step_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_heating_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.qube_heat_pump_heating_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Heating alarm', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heating alarm', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_heating', + 'unique_id': '01JQUBEHEATPUMP00000000000-alarm_heating', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_heating_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Qube heat pump Heating alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_heating_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_keypad-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.qube_heat_pump_keypad', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Keypad', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Keypad', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'keypad', + 'unique_id': '01JQUBEHEATPUMP00000000000-keypad', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_keypad-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Qube heat pump Keypad', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_keypad', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_plant_demand-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.qube_heat_pump_plant_demand', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Plant demand', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Plant demand', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plant_demand', + 'unique_id': '01JQUBEHEATPUMP00000000000-plant_demand', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_plant_demand-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Qube heat pump Plant demand', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_plant_demand', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_plant_sensor_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.qube_heat_pump_plant_sensor_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Plant sensor enabled', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Plant sensor enabled', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'plant_sensor_enabled', + 'unique_id': '01JQUBEHEATPUMP00000000000-plant_sensor_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_plant_sensor_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Qube heat pump Plant sensor enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_plant_sensor_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_pv_surplus-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.qube_heat_pump_pv_surplus', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PV surplus', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PV surplus', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pv_surplus', + 'unique_id': '01JQUBEHEATPUMP00000000000-pv_surplus', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_pv_surplus-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Qube heat pump PV surplus', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_pv_surplus', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_room_sensor_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.qube_heat_pump_room_sensor_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Room sensor enabled', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Room sensor enabled', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'room_sensor_enabled', + 'unique_id': '01JQUBEHEATPUMP00000000000-room_sensor_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_room_sensor_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Qube heat pump Room sensor enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_room_sensor_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_source_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.qube_heat_pump_source_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Source alarm', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Source alarm', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_source', + 'unique_id': '01JQUBEHEATPUMP00000000000-alarm_source', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_source_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Qube heat pump Source alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_source_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_source_flow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.qube_heat_pump_source_flow', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Source flow', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Source flow', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'source_flow', + 'unique_id': '01JQUBEHEATPUMP00000000000-source_flow', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_source_flow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Qube heat pump Source flow', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_source_flow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_source_pump-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.qube_heat_pump_source_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Source pump', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Source pump', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'source_pump', + 'unique_id': '01JQUBEHEATPUMP00000000000-source_pump', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_source_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Qube heat pump Source pump', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_source_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_summer_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.qube_heat_pump_summer_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Summer mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Summer mode', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'summer_mode', + 'unique_id': '01JQUBEHEATPUMP00000000000-summer_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_summer_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Qube heat pump Summer mode', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_summer_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_supply_too_hot_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.qube_heat_pump_supply_too_hot_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Supply too hot alarm', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Supply too hot alarm', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_supply_too_hot', + 'unique_id': '01JQUBEHEATPUMP00000000000-alarm_supply_too_hot', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_supply_too_hot_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Qube heat pump Supply too hot alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_supply_too_hot_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_thermostat_demand-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.qube_heat_pump_thermostat_demand', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Thermostat demand', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thermostat demand', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'thermostat_demand', + 'unique_id': '01JQUBEHEATPUMP00000000000-thermostat_demand', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_thermostat_demand-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Qube heat pump Thermostat demand', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_thermostat_demand', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_three_way_valve-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.qube_heat_pump_three_way_valve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Three-way valve', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Three-way valve', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'three_way_valve', + 'unique_id': '01JQUBEHEATPUMP00000000000-three_way_valve', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_three_way_valve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Qube heat pump Three-way valve', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_three_way_valve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_user_pump-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.qube_heat_pump_user_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'User pump', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'User pump', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'user_pump', + 'unique_id': '01JQUBEHEATPUMP00000000000-user_pump', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_user_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Qube heat pump User pump', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_user_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_working_hours_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.qube_heat_pump_working_hours_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Working hours alarm', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Working hours alarm', + 'platform': 'hr_energy_qube', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_working_hours', + 'unique_id': '01JQUBEHEATPUMP00000000000-alarm_working_hours', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.qube_heat_pump_working_hours_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Qube heat pump Working hours alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.qube_heat_pump_working_hours_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/hr_energy_qube/test_binary_sensor.py b/tests/components/hr_energy_qube/test_binary_sensor.py new file mode 100644 index 0000000000000..da1ab7ec3843b --- /dev/null +++ b/tests/components/hr_energy_qube/test_binary_sensor.py @@ -0,0 +1,74 @@ +"""Tests for the Qube Heat Pump binary sensor platform.""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_qube_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test all binary sensor entities via snapshot.""" + with patch( + "homeassistant.components.hr_energy_qube.PLATFORMS", + [Platform.BINARY_SENSOR], + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("side_effect", "return_value"), + [ + (ConnectionError("Connection lost"), None), + (None, None), + ], +) +async def test_binary_sensor_unavailable_on_coordinator_error( + hass: HomeAssistant, + mock_qube_client: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + side_effect: Exception | None, + return_value: None, +) -> None: + """Test binary sensors become unavailable when coordinator fails.""" + await setup_integration(hass, mock_config_entry) + + # Verify binary sensors are available after setup + states = hass.states.async_all("binary_sensor") + assert len(states) > 0 + assert all(s.state != STATE_UNAVAILABLE for s in states) + + # Make the next fetch fail + mock_qube_client.get_all_data = AsyncMock( + side_effect=side_effect, return_value=return_value + ) + + # Skip time to trigger coordinator refresh + freezer.tick(timedelta(seconds=31)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # All binary sensors should be unavailable + states = hass.states.async_all("binary_sensor") + assert all(s.state == STATE_UNAVAILABLE for s in states) diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 095ae8ad17a80..6997b78d8e45d 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -592,6 +592,91 @@ async def test_local_only_user_rejected( assert req.status == HTTPStatus.UNAUTHORIZED +async def test_auth_access_signed_path_with_local_only_user( + hass: HomeAssistant, + app: web.Application, + aiohttp_client: ClientSessionGenerator, + hass_access_token: str, +) -> None: + """Test access with signed url for a local-only user.""" + app.router.add_post("/", mock_handler) + app.router.add_get("/another_path", mock_handler) + await async_setup_auth(hass, app) + set_mock_ip = mock_real_ip(app) + client = await aiohttp_client(app) + + refresh_token = hass.auth.async_validate_access_token(hass_access_token) + refresh_token.user.local_only = True + + signed_path = async_sign_path( + hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id + ) + + # Local IP is allowed for local-only user + set_mock_ip("192.168.1.123") + + req = await client.head(signed_path) + assert req.status == HTTPStatus.OK + + req = await client.get(signed_path) + assert req.status == HTTPStatus.OK + data = await req.json() + assert data["user_id"] == refresh_token.user.id + + # Remote IP is rejected for local-only user + for remote_addr in EXTERNAL_ADDRESSES: + set_mock_ip(remote_addr) + signed_path = async_sign_path( + hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id + ) + + req = await client.head(signed_path) + assert req.status == HTTPStatus.UNAUTHORIZED + + req = await client.get(signed_path) + assert req.status == HTTPStatus.UNAUTHORIZED + + +async def test_auth_access_signed_path_with_inactive_user( + hass: HomeAssistant, + app: web.Application, + aiohttp_client: ClientSessionGenerator, + hass_access_token: str, +) -> None: + """Test access with signed url for an inactive user.""" + app.router.add_post("/", mock_handler) + app.router.add_get("/another_path", mock_handler) + await async_setup_auth(hass, app) + client = await aiohttp_client(app) + + refresh_token = hass.auth.async_validate_access_token(hass_access_token) + + signed_path = async_sign_path( + hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id + ) + + # Active user is allowed + req = await client.head(signed_path) + assert req.status == HTTPStatus.OK + + req = await client.get(signed_path) + assert req.status == HTTPStatus.OK + data = await req.json() + assert data["user_id"] == refresh_token.user.id + signed_path = async_sign_path( + hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id + ) + + # Inactive user is rejected + refresh_token.user.is_active = False + + req = await client.head(signed_path) + assert req.status == HTTPStatus.UNAUTHORIZED + + req = await client.get(signed_path) + assert req.status == HTTPStatus.UNAUTHORIZED + + async def test_async_user_not_allowed_do_auth( hass: HomeAssistant, app: web.Application ) -> None: diff --git a/tests/components/kitchen_sink/test_radio_frequency.py b/tests/components/kitchen_sink/test_radio_frequency.py new file mode 100644 index 0000000000000..4cf19865d5455 --- /dev/null +++ b/tests/components/kitchen_sink/test_radio_frequency.py @@ -0,0 +1,53 @@ +"""The tests for the kitchen_sink radio frequency platform.""" + +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from rf_protocols import OOKCommand + +from homeassistant.components.kitchen_sink import DOMAIN +from homeassistant.components.radio_frequency import async_send_command +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +ENTITY_RF_TRANSMITTER = "radio_frequency.rf_blaster_radio_frequency_transmitter" + + +@pytest.fixture +async def radio_frequency_only() -> None: + """Enable only the radio_frequency platform.""" + with patch( + "homeassistant.components.kitchen_sink.COMPONENTS_WITH_DEMO_PLATFORM", + [Platform.RADIO_FREQUENCY], + ): + yield + + +@pytest.fixture(autouse=True) +async def setup_comp(hass: HomeAssistant, radio_frequency_only: None) -> None: + """Set up demo component.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + +async def test_send_command( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test sending a radio frequency command.""" + state = hass.states.get(ENTITY_RF_TRANSMITTER) + assert state + assert state.state == STATE_UNKNOWN + + now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") + assert now is not None + freezer.move_to(now) + + command = OOKCommand(frequency=433_920_000, timings=[350, -1050, 350, -350]) + await async_send_command(hass, ENTITY_RF_TRANSMITTER, command) + + state = hass.states.get(ENTITY_RF_TRANSMITTER) + assert state + assert state.state == now.isoformat(timespec="milliseconds") diff --git a/tests/components/knx/snapshots/test_websocket.ambr b/tests/components/knx/snapshots/test_websocket.ambr index cfc5ec81064dc..49299a9537a47 100644 --- a/tests/components/knx/snapshots/test_websocket.ambr +++ b/tests/components/knx/snapshots/test_websocket.ambr @@ -2216,6 +2216,7 @@ 'options': list([ 'date', 'timestamp', + 'uptime', 'absolute_humidity', 'apparent_power', 'aqi', diff --git a/tests/components/mqtt/test_date.py b/tests/components/mqtt/test_date.py new file mode 100644 index 0000000000000..560baa58aa69d --- /dev/null +++ b/tests/components/mqtt/test_date.py @@ -0,0 +1,589 @@ +"""The tests for the MQTT date platform.""" + +from __future__ import annotations + +import datetime +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components import date, mqtt +from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from .common import ( + help_custom_config, + help_test_availability_when_connection_lost, + help_test_availability_without_topic, + help_test_custom_availability_payload, + help_test_default_availability_payload, + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_discovery_update_unchanged, + help_test_encoding_subscribable_topics, + help_test_entity_debug_info_message, + help_test_entity_device_info_remove, + help_test_entity_device_info_update, + help_test_entity_device_info_with_connection, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update_discovery_update, + help_test_entity_id_update_subscriptions, + help_test_reloadable, + help_test_setting_attribute_via_mqtt_json_message, + help_test_setting_attribute_with_template, + help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, + help_test_unique_id, + help_test_unload_config_entry_with_platform, + help_test_update_with_json_attrs_bad_json, + help_test_update_with_json_attrs_not_dict, +) + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient + +DEFAULT_CONFIG = { + mqtt.DOMAIN: {date.DOMAIN: {"name": "test", "command_topic": "test-topic"}} +} + + +async def async_set_value( + hass: HomeAssistant, entity_id: str, value: datetime.date | None +) -> None: + """Set date value.""" + await hass.services.async_call( + date.DOMAIN, + date.SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, date.ATTR_DATE: value}, + blocking=True, + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + date.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + } + } + } + ], +) +async def test_controlling_state_via_topic( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry() + + state = hass.states.get("date.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", "1/12/2025") + state = hass.states.get("date.test") + assert state.state == "2025-01-12" + + async_fire_mqtt_message(hass, "state-topic", "2025-12-02") + state = hass.states.get("date.test") + assert state.state == "2025-12-02" + + async_fire_mqtt_message(hass, "state-topic", "None") + state = hass.states.get("date.test") + assert state.state == STATE_UNKNOWN + + # Empty string should be ignored + caplog.clear() + async_fire_mqtt_message(hass, "state-topic", "") + assert "Ignoring empty state payload" in caplog.text + + state = hass.states.get("date.test") + assert state.state == STATE_UNKNOWN + + # Invalid value should show warning + caplog.clear() + async_fire_mqtt_message(hass, "state-topic", "No valid date") + assert "Invalid received date expression" in caplog.text + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + date.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + } + } + } + ], +) +@pytest.mark.parametrize( + ("received_state", "expected_state"), + [ + ("1 March 2025", "2025-03-01"), + ("2025.03.01", "2025-03-01"), + ("2025-03-01 00:00:00", "2025-03-01"), + ], +) +async def test_controlling_validation_state_via_topic( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + received_state: str, + expected_state: str, +) -> None: + """Test the validation of a received state.""" + await mqtt_mock_entry() + + state = hass.states.get("date.test") + assert state.state == STATE_UNKNOWN + + async_fire_mqtt_message(hass, "state-topic", received_state) + state = hass.states.get("date.test") + assert state.state == expected_state + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + date.DOMAIN: { + "name": "test", + "command_topic": "command-topic", + "qos": "2", + } + } + } + ], +) +async def test_sending_mqtt_commands_and_optimistic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the sending MQTT commands in optimistic mode.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("date.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await async_set_value(hass, "date.test", datetime.date(year=2025, month=12, day=1)) + await hass.async_block_till_done() + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", "2025-12-01", 2, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("date.test") + assert state.state == "2025-12-01" + + await async_set_value(hass, "date.test", datetime.date(year=2025, month=12, day=2)) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", "2025-12-02", 2, False + ) + state = hass.states.get("date.test") + assert state.state == "2025-12-02" + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_when_connection_lost( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability after MQTT disconnection.""" + await help_test_availability_when_connection_lost( + hass, mqtt_mock_entry, date.DOMAIN + ) + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_without_topic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability without defined availability topic.""" + await help_test_availability_without_topic( + hass, mqtt_mock_entry, date.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by default payload with defined topic.""" + config = { + mqtt.DOMAIN: { + date.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + } + } + } + await help_test_default_availability_payload( + hass, mqtt_mock_entry, date.DOMAIN, config, True, "state-topic", "10:12" + ) + + +async def test_custom_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by custom payload with defined topic.""" + config = { + mqtt.DOMAIN: { + date.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + } + } + } + + await help_test_custom_availability_payload( + hass, mqtt_mock_entry, date.DOMAIN, config, True, "state-topic", "10:12" + ) + + +async def test_setting_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry, date.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_blocked_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry, date.DOMAIN, DEFAULT_CONFIG, None + ) + + +async def test_setting_attribute_with_template( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_with_template( + hass, mqtt_mock_entry, date.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_not_dict( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_not_dict( + hass, mqtt_mock_entry, caplog, date.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_bad_json( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_bad_json( + hass, mqtt_mock_entry, caplog, date.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_discovery_update_attr( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test update of discovered MQTTAttributes.""" + await help_test_discovery_update_attr( + hass, mqtt_mock_entry, date.DOMAIN, DEFAULT_CONFIG + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + date.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) +async def test_unique_id( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test unique id option only creates one date per unique_id.""" + await help_test_unique_id(hass, mqtt_mock_entry, date.DOMAIN) + + +async def test_discovery_removal_time( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test removal of discovered date entity.""" + data = ( + '{ "name": "test",' + ' "state_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + await help_test_discovery_removal(hass, mqtt_mock_entry, date.DOMAIN, data) + + +async def test_discovery_time_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test update of discovered date entity.""" + config1 = { + "name": "Beer", + "command_topic": "command-topic", + "state_topic": "state-topic", + } + config2 = { + "name": "Milk", + "command_topic": "command-topic", + "state_topic": "state-topic", + } + + await help_test_discovery_update( + hass, mqtt_mock_entry, date.DOMAIN, config1, config2 + ) + + +async def test_discovery_update_unchanged_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test update of discovered update.""" + data1 = '{ "name": "Beer", "state_topic": "date-topic", "command_topic": "command-topic"}' + with patch( + "homeassistant.components.mqtt.date.MqttDateEntity.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock_entry, date.DOMAIN, data1, discovery_update + ) + + +@pytest.mark.no_fail_on_log_exception +async def test_discovery_broken( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test handling of bad discovery message.""" + data1 = '{ "name": "Beer" }' + data2 = ( + '{ "name": "Milk",' + ' "state_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + await help_test_discovery_broken(hass, mqtt_mock_entry, date.DOMAIN, data1, data2) + + +async def test_entity_device_info_with_connection( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT date device registry integration.""" + await help_test_entity_device_info_with_connection( + hass, mqtt_mock_entry, date.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_with_identifier( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT date device registry integration.""" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock_entry, date.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry update.""" + await help_test_entity_device_info_update( + hass, mqtt_mock_entry, date.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_remove( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry remove.""" + await help_test_entity_device_info_remove( + hass, mqtt_mock_entry, date.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_subscriptions( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT subscriptions are managed when entity_id is updated.""" + await help_test_entity_id_update_subscriptions( + hass, mqtt_mock_entry, date.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_discovery_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock_entry, date.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_debug_info_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT debug info.""" + await help_test_entity_debug_info_message( + hass, mqtt_mock_entry, date.DOMAIN, DEFAULT_CONFIG, None + ) + + +async def test_reloadable( + hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient +) -> None: + """Test reloading the MQTT platform.""" + domain = date.DOMAIN + config = DEFAULT_CONFIG + await help_test_reloadable(hass, mqtt_client_mock, domain, config) + + +@pytest.mark.parametrize( + ("topic", "value", "attribute", "attribute_value"), + [ + ("state_topic", "2025-12-01", None, "2025-12-01"), + ], +) +async def test_encoding_subscribable_topics( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, +) -> None: + """Test handling of incoming encoded payload.""" + await help_test_encoding_subscribable_topics( + hass, + mqtt_mock_entry, + date.DOMAIN, + DEFAULT_CONFIG[mqtt.DOMAIN][date.DOMAIN], + topic, + value, + attribute, + attribute_value, + ) + + +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry() + platform = date.DOMAIN + assert hass.states.get(f"{platform}.test") + + +async def test_unload_entry( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test unloading the config entry.""" + domain = date.DOMAIN + config = DEFAULT_CONFIG + await help_test_unload_config_entry_with_platform( + hass, mqtt_mock_entry, domain, config + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + date.DOMAIN, + DEFAULT_CONFIG, + ( + { + "state_topic": "test-topic", + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", "2025-12-01", "2025-12-02"), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + date.DOMAIN, + DEFAULT_CONFIG, + ( + { + "state_topic": "test-topic", + "value_template": "{{ value_json.some_var * 1 }}", + }, + ), + ) + ], +) +async def test_value_template_fails( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the rendering of MQTT value template fails.""" + await mqtt_mock_entry() + async_fire_mqtt_message(hass, "test-topic", '{"some_var": null }') + assert ( + "TypeError: unsupported operand type(s) for *: 'NoneType' and 'int' rendering template" + in caplog.text + ) diff --git a/tests/components/radio_frequency/__init__.py b/tests/components/radio_frequency/__init__.py new file mode 100644 index 0000000000000..a2b426cd378bd --- /dev/null +++ b/tests/components/radio_frequency/__init__.py @@ -0,0 +1,3 @@ +"""Tests for the Radio Frequency integration.""" + +ENTITY_ID = "radio_frequency.test_rf_transmitter" diff --git a/tests/components/radio_frequency/conftest.py b/tests/components/radio_frequency/conftest.py new file mode 100644 index 0000000000000..e4e651204e6bd --- /dev/null +++ b/tests/components/radio_frequency/conftest.py @@ -0,0 +1,92 @@ +"""Common fixtures for the Radio Frequency tests.""" + +from typing import NamedTuple, override + +import pytest +from rf_protocols import ModulationType, RadioFrequencyCommand + +from homeassistant.components.radio_frequency import ( + DATA_COMPONENT, + RadioFrequencyTransmitterEntity, +) +from homeassistant.components.radio_frequency.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +@pytest.fixture +async def init_integration(hass: HomeAssistant) -> None: + """Set up the Radio Frequency integration for testing.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + +class MockCommand(NamedTuple): + """Data structure to store calls to async_send_command.""" + + command: RadioFrequencyCommand + context: object | None + + +class MockRadioFrequencyCommand(RadioFrequencyCommand): + """Mock RF command for testing.""" + + def __init__( + self, + *, + frequency: int = 433_920_000, + modulation: ModulationType = ModulationType.OOK, + repeat_count: int = 0, + ) -> None: + """Initialize mock command.""" + super().__init__( + frequency=frequency, modulation=modulation, repeat_count=repeat_count + ) + + @override + def get_raw_timings(self) -> list[int]: + """Return mock timings.""" + return [350, -1050, 350, -350] + + +class MockRadioFrequencyEntity(RadioFrequencyTransmitterEntity): + """Mock radio frequency entity for testing.""" + + _attr_has_entity_name = True + _attr_name = "Test RF transmitter" + + def __init__( + self, + unique_id: str, + frequency_ranges: list[tuple[int, int]] | None = None, + ) -> None: + """Initialize mock entity.""" + self._attr_unique_id = unique_id + self._frequency_ranges = ( + [(433_000_000, 434_000_000)] + if frequency_ranges is None + else frequency_ranges + ) + self.send_command_calls: list[MockCommand] = [] + + @property + def supported_frequency_ranges(self) -> list[tuple[int, int]]: + """Return supported frequency ranges.""" + return self._frequency_ranges + + async def async_send_command(self, command: RadioFrequencyCommand) -> None: + """Mock send command.""" + self.send_command_calls.append( + MockCommand(command=command, context=self._context) + ) + + +@pytest.fixture +async def mock_rf_entity( + hass: HomeAssistant, init_integration: None +) -> MockRadioFrequencyEntity: + """Return a mock radio frequency entity.""" + entity = MockRadioFrequencyEntity("test_rf_transmitter") + component = hass.data[DATA_COMPONENT] + await component.async_add_entities([entity]) + return entity diff --git a/tests/components/radio_frequency/test_init.py b/tests/components/radio_frequency/test_init.py new file mode 100644 index 0000000000000..f8c42c198a45c --- /dev/null +++ b/tests/components/radio_frequency/test_init.py @@ -0,0 +1,200 @@ +"""Tests for the Radio Frequency integration setup.""" + +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +from rf_protocols import ModulationType + +from homeassistant.components.radio_frequency import ( + DATA_COMPONENT, + DOMAIN, + async_get_transmitters, + async_send_command, +) +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant, State +from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from . import ENTITY_ID +from .conftest import MockRadioFrequencyCommand, MockRadioFrequencyEntity + +from tests.common import mock_restore_cache + + +async def test_get_transmitters_component_not_loaded(hass: HomeAssistant) -> None: + """Test getting transmitters raises when the component is not loaded.""" + with pytest.raises(HomeAssistantError, match="component_not_loaded"): + async_get_transmitters(hass, 433_920_000, ModulationType.OOK) + + +@pytest.mark.usefixtures("init_integration") +async def test_get_transmitters_no_entities(hass: HomeAssistant) -> None: + """Test getting transmitters raises when none are registered.""" + with pytest.raises( + HomeAssistantError, + match="No Radio Frequency transmitters available", + ): + async_get_transmitters(hass, 433_920_000, ModulationType.OOK) + + +@pytest.mark.usefixtures("mock_rf_entity") +async def test_get_transmitters_with_frequency_ranges(hass: HomeAssistant) -> None: + """Test transmitter with frequency ranges filters correctly.""" + # 433.92 MHz is within 433-434 MHz range + result = async_get_transmitters(hass, 433_920_000, ModulationType.OOK) + assert result == [ENTITY_ID] + + # 868 MHz is outside the range + result = async_get_transmitters(hass, 868_000_000, ModulationType.OOK) + assert result == [] + + +@pytest.mark.usefixtures("mock_rf_entity") +async def test_get_transmitters_filters_by_modulation(hass: HomeAssistant) -> None: + """Test transmitters are filtered by supported modulation.""" + result = async_get_transmitters(hass, 433_920_000, "no_matching_modulation") # type: ignore[arg-type] + assert result == [] + + +@pytest.mark.usefixtures("mock_rf_entity") +async def test_rf_entity_initial_state(hass: HomeAssistant) -> None: + """Test radio frequency entity has no state before any command is sent.""" + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + +async def test_async_send_command_success( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, + freezer: FrozenDateTimeFactory, +) -> None: + """Test sending command via async_send_command helper.""" + now = dt_util.utcnow() + freezer.move_to(now) + + command = MockRadioFrequencyCommand(frequency=433_920_000) + await async_send_command(hass, ENTITY_ID, command) + + assert len(mock_rf_entity.send_command_calls) == 1 + assert mock_rf_entity.send_command_calls[0].command is command + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == now.isoformat(timespec="milliseconds") + + +async def test_async_send_command_error_does_not_update_state( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, +) -> None: + """Test that state is not updated when async_send_command raises an error.""" + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + command = MockRadioFrequencyCommand(frequency=433_920_000) + + mock_rf_entity.async_send_command = AsyncMock( + side_effect=HomeAssistantError("Transmission failed") + ) + + with pytest.raises(HomeAssistantError, match="Transmission failed"): + await async_send_command(hass, ENTITY_ID, command) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("init_integration") +async def test_async_send_command_entity_not_found(hass: HomeAssistant) -> None: + """Test async_send_command raises error when entity not found.""" + command = MockRadioFrequencyCommand(frequency=433_920_000) + + with pytest.raises( + HomeAssistantError, + match="Radio Frequency entity `radio_frequency.nonexistent_entity` not found", + ): + await async_send_command(hass, "radio_frequency.nonexistent_entity", command) + + +async def test_async_send_command_unsupported_frequency( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, +) -> None: + """Test async_send_command raises when the frequency is not supported.""" + command = MockRadioFrequencyCommand(frequency=868_000_000) + + with pytest.raises( + HomeAssistantError, + match=( + f"Radio Frequency entity `{ENTITY_ID}` " + "does not support frequency 868000000 Hz" + ), + ): + await async_send_command(hass, ENTITY_ID, command) + + assert mock_rf_entity.send_command_calls == [] + + +@pytest.mark.usefixtures("mock_rf_entity") +async def test_async_send_command_unsupported_modulation( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, +) -> None: + """Test async_send_command raises when the modulation is not supported.""" + command = MockRadioFrequencyCommand( + frequency=433_920_000, + modulation="incorrect_modulation", # type: ignore[arg-type] + ) + + with pytest.raises( + HomeAssistantError, + match=( + f"Radio Frequency entity `{ENTITY_ID}` " + "does not support modulation incorrect_modulation" + ), + ): + await async_send_command(hass, ENTITY_ID, command) + + assert mock_rf_entity.send_command_calls == [] + + +async def test_async_send_command_component_not_loaded(hass: HomeAssistant) -> None: + """Test async_send_command raises error when component not loaded.""" + command = MockRadioFrequencyCommand(frequency=433_920_000) + + with pytest.raises(HomeAssistantError, match="component_not_loaded"): + await async_send_command(hass, "radio_frequency.some_entity", command) + + +@pytest.mark.parametrize( + ("restored_value", "expected_state"), + [ + ("2026-01-01T12:00:00.000+00:00", "2026-01-01T12:00:00.000+00:00"), + (STATE_UNAVAILABLE, STATE_UNKNOWN), + ], +) +async def test_rf_entity_state_restore( + hass: HomeAssistant, + restored_value: str, + expected_state: str, +) -> None: + """Test radio frequency entity state restore.""" + mock_restore_cache(hass, [State(ENTITY_ID, restored_value)]) + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + component = hass.data[DATA_COMPONENT] + await component.async_add_entities( + [MockRadioFrequencyEntity("test_rf_transmitter")] + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == expected_state diff --git a/tests/components/sensor/common.py b/tests/components/sensor/common.py index 4dedababad1ce..3721e7be22065 100644 --- a/tests/components/sensor/common.py +++ b/tests/components/sensor/common.py @@ -95,6 +95,7 @@ SensorDeviceClass.TEMPERATURE: UnitOfTemperature.CELSIUS, SensorDeviceClass.TEMPERATURE_DELTA: UnitOfTemperature.CELSIUS, SensorDeviceClass.TIMESTAMP: None, + SensorDeviceClass.UPTIME: None, SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: CONCENTRATION_PARTS_PER_MILLION, SensorDeviceClass.VOLTAGE: UnitOfElectricPotential.VOLT, diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py index 67f07e3293a36..59aeb8cc8d535 100644 --- a/tests/components/sensor/test_device_condition.py +++ b/tests/components/sensor/test_device_condition.py @@ -101,6 +101,7 @@ async def test_get_conditions( SensorDeviceClass.DATE, SensorDeviceClass.ENUM, SensorDeviceClass.TIMESTAMP, + SensorDeviceClass.UPTIME, } expected_conditions = [ { @@ -202,6 +203,7 @@ async def test_get_conditions_no_state( SensorDeviceClass.DATE, # No condition SensorDeviceClass.ENUM, # No condition SensorDeviceClass.TIMESTAMP, # No condition + SensorDeviceClass.UPTIME, # No condition SensorDeviceClass.AQI, # No unit of measurement SensorDeviceClass.PH, # No unit of measurement SensorDeviceClass.MONETARY, # No unit of measurement diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index 8b407ac5576c0..d796dd1158a66 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -103,6 +103,7 @@ async def test_get_triggers( SensorDeviceClass.DATE, SensorDeviceClass.ENUM, SensorDeviceClass.TIMESTAMP, + SensorDeviceClass.UPTIME, } expected_triggers = [ { diff --git a/tests/components/sensor/test_helpers.py b/tests/components/sensor/test_helpers.py index e197579fa6674..2fc89f2585577 100644 --- a/tests/components/sensor/test_helpers.py +++ b/tests/components/sensor/test_helpers.py @@ -6,10 +6,15 @@ from homeassistant.components.sensor.helpers import async_parse_date_datetime -def test_async_parse_datetime(caplog: pytest.LogCaptureFixture) -> None: +@pytest.mark.parametrize( + "device_class", + [SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME], +) +def test_async_parse_datetime( + caplog: pytest.LogCaptureFixture, device_class: SensorDeviceClass +) -> None: """Test async_parse_date_datetime.""" entity_id = "sensor.timestamp" - device_class = SensorDeviceClass.TIMESTAMP assert ( async_parse_date_datetime( "2021-12-12 12:12Z", entity_id, device_class diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index e9ae2ba4f7520..3a03a608ce880 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Generator -from datetime import UTC, date, datetime +from datetime import UTC, date, datetime, timedelta from decimal import Decimal import math from typing import Any @@ -23,6 +23,7 @@ DEVICE_CLASS_UNITS, DOMAIN, NON_NUMERIC_DEVICE_CLASSES, + UPTIME_DEFAULT_TOLERANCE_SECONDS, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -283,6 +284,44 @@ async def test_datetime_conversion( assert state.state == test_timestamp.isoformat() +@pytest.mark.parametrize("drift_tolerance", [UPTIME_DEFAULT_TOLERANCE_SECONDS, 10]) +async def test_uptime_device_class_auto_normalizes_drift( + hass: HomeAssistant, drift_tolerance +) -> None: + """Test uptime device class suppresses small drift automatically.""" + initial_uptime = datetime(2026, 2, 14, 9, 30, tzinfo=UTC) + entity = MockSensor( + name="Test", + native_value=initial_uptime, + device_class=SensorDeviceClass.UPTIME, + ) + entity._attr_uptime_drift_tolerance = drift_tolerance + setup_test_component_platform(hass, sensor.DOMAIN, [entity]) + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity.entity_id)) + assert state.state == initial_uptime.isoformat(timespec="seconds") + + entity._values["native_value"] = initial_uptime + timedelta( + seconds=drift_tolerance - 1 + ) + entity.async_write_ha_state() + await hass.async_block_till_done() + + assert (state := hass.states.get(entity.entity_id)) + assert state.state == initial_uptime.isoformat(timespec="seconds") + + updated_uptime = initial_uptime + timedelta(seconds=drift_tolerance + 1) + entity._values["native_value"] = updated_uptime + entity.async_write_ha_state() + await hass.async_block_till_done() + + assert (state := hass.states.get(entity.entity_id)) + assert state.state == updated_uptime.isoformat(timespec="seconds") + + async def test_a_sensor_with_a_non_numeric_device_class( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -2200,6 +2239,7 @@ async def test_invalid_enumeration_entity_without_device_class( SensorDeviceClass.DATE, SensorDeviceClass.ENUM, SensorDeviceClass.TIMESTAMP, + SensorDeviceClass.UPTIME, ], ) async def test_non_numeric_device_class_with_unit_of_measurement( @@ -2554,6 +2594,7 @@ async def test_device_classes_with_invalid_state_class( (SensorDeviceClass.ENUM, None, None, None, False), (SensorDeviceClass.DATE, None, None, None, False), (SensorDeviceClass.TIMESTAMP, None, None, None, False), + (SensorDeviceClass.UPTIME, None, None, None, False), ("custom", None, None, None, False), (SensorDeviceClass.POWER, None, "V", None, True), (None, SensorStateClass.MEASUREMENT, None, None, True), @@ -3097,6 +3138,7 @@ def test_device_class_units_are_complete() -> None: SensorDeviceClass.ENUM, SensorDeviceClass.MONETARY, SensorDeviceClass.TIMESTAMP, + SensorDeviceClass.UPTIME, } unit_device_classes = { device_class.value for device_class in SensorDeviceClass @@ -3126,6 +3168,7 @@ def test_device_class_converters_are_complete() -> None: SensorDeviceClass.SIGNAL_STRENGTH, SensorDeviceClass.SOUND_PRESSURE, SensorDeviceClass.TIMESTAMP, + SensorDeviceClass.UPTIME, SensorDeviceClass.WIND_DIRECTION, } converter_device_classes = { diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index b00fe06e19330..33e27dd27ae66 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -137,6 +137,9 @@ async def test_device_registry_not_portable( assert reg_device.area_id == area_registry.async_get_area_by_name("Zone A").id +@pytest.mark.skip( + reason="Flaky due to Python 3.14.3 asyncio changes - see home-assistant/core#162263" +) async def test_entity_basic( hass: HomeAssistant, async_autosetup_sonos, diff --git a/tests/components/sonos/test_repairs.py b/tests/components/sonos/test_repairs.py index 487020e0b122f..6a677b156aaf5 100644 --- a/tests/components/sonos/test_repairs.py +++ b/tests/components/sonos/test_repairs.py @@ -2,6 +2,7 @@ from unittest.mock import Mock +import pytest from soco import SoCo from homeassistant.components.sonos.const import ( @@ -18,6 +19,9 @@ from tests.common import MockConfigEntry, async_fire_time_changed +@pytest.mark.skip( + reason="Flaky due to Python 3.14.3 asyncio changes - see home-assistant/core#162263" +) async def test_subscription_repair_issues( hass: HomeAssistant, config_entry: MockConfigEntry, diff --git a/tests/components/sonos/test_speaker.py b/tests/components/sonos/test_speaker.py index c96805779f8b5..b5931f39c9e50 100644 --- a/tests/components/sonos/test_speaker.py +++ b/tests/components/sonos/test_speaker.py @@ -101,6 +101,9 @@ async def _media_play(hass: HomeAssistant, entity: str) -> None: ) +@pytest.mark.skip( + reason="Flaky due to Python 3.14.3 asyncio changes - see home-assistant/core#162263" +) async def test_zgs_event_group_speakers( hass: HomeAssistant, sonos_setup_two_speakers: list[MockSoCo] ) -> None: diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index e5e64ba7d36cb..cec1e5976e286 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -4,6 +4,7 @@ from contextlib import AbstractContextManager, nullcontext as does_not_raise from datetime import timedelta import io +import logging from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -1145,6 +1146,11 @@ async def test_time_using_sensor(hass: HomeAssistant) -> None: "2020-06-01 01:00:00.000000+00:00", # 6 pm local time {ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP}, ) + hass.states.async_set( + "sensor.uptime_am", + "2021-06-03 13:00:00.000000+00:00", # 6 am local time + {ATTR_DEVICE_CLASS: SensorDeviceClass.UPTIME}, + ) hass.states.async_set( "sensor.no_device_class", "2020-06-01 01:00:00.000000+00:00", @@ -1167,6 +1173,7 @@ async def test_time_using_sensor(hass: HomeAssistant) -> None: return_value=dt_util.now().replace(hour=9), ): assert condition.time(hass, after="sensor.am", before="sensor.pm") + assert condition.time(hass, after="sensor.uptime_am", before="sensor.pm") assert not condition.time(hass, after="sensor.pm", before="sensor.am") with patch( @@ -4441,13 +4448,13 @@ async def test_compound_condition_forwards_async_unload( config = await condition.async_validate_condition_config(hass, config) test = await condition.async_from_config(hass, config) - # The compound checker should hold child checkers - assert hasattr(test, "_checks") - assert len(test._checks) == 2 + # The compound checker should hold child conditions + assert hasattr(test, "_conditions") + assert len(test._conditions) == 2 test.async_unload() - for child in test._checks: + for child in test._conditions: child.async_unload.assert_called_once() @@ -4479,12 +4486,68 @@ async def test_nested_compound_condition_forwards_async_unload( test = await condition.async_from_config(hass, config) # Outer compound with 2 children: an inner compound and a leaf - assert len(test._checks) == 2 - inner_checker = test._checks[0] - assert hasattr(inner_checker, "_checks") - assert len(inner_checker._checks) == 1 + assert len(test._conditions) == 2 + inner_checker = test._conditions[0] + assert hasattr(inner_checker, "_conditions") + assert len(inner_checker._conditions) == 1 + + test.async_unload() + + test._conditions[0]._conditions[0].async_unload.assert_called_once() + test._conditions[1].async_unload.assert_called_once() + + +async def test_conditions_from_config_forwards_async_unload( + hass: HomeAssistant, +) -> None: + """Test that async_conditions_from_config forwards async_unload to children.""" + await _setup_mock_integration(hass) + configs = [ + await condition.async_validate_condition_config(hass, {"condition": "test"}), + await condition.async_validate_condition_config(hass, {"condition": "test"}), + ] + test = await condition.async_conditions_from_config( + hass, configs, logging.getLogger(__name__), "test" + ) + + assert hasattr(test, "_conditions") + assert len(test._conditions) == 2 + + test.async_unload() + + for child in test._conditions: + child.async_unload.assert_called_once() + + +@pytest.mark.parametrize( + "inner_type", + ["and", "or", "not"], +) +async def test_conditions_from_config_nested_forwards_async_unload( + hass: HomeAssistant, inner_type: str +) -> None: + """Test that async_conditions_from_config forwards async_unload recursively.""" + await _setup_mock_integration(hass) + configs = [ + await condition.async_validate_condition_config( + hass, + { + "condition": inner_type, + "conditions": [{"condition": "test"}], + }, + ), + await condition.async_validate_condition_config(hass, {"condition": "test"}), + ] + test = await condition.async_conditions_from_config( + hass, configs, logging.getLogger(__name__), "test" + ) + + assert len(test._conditions) == 2 + inner_checker = test._conditions[0] + assert hasattr(inner_checker, "_conditions") + assert len(inner_checker._conditions) == 1 test.async_unload() - test._checks[0]._checks[0].async_unload.assert_called_once() - test._checks[1].async_unload.assert_called_once() + test._conditions[0]._conditions[0].async_unload.assert_called_once() + test._conditions[1].async_unload.assert_called_once() diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index c0eae41064324..853ac7e9fdbc7 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -7,6 +7,7 @@ import logging import operator from types import MappingProxyType +from typing import Any from unittest import mock from unittest.mock import ANY, AsyncMock, MagicMock, patch @@ -41,6 +42,7 @@ template, trace, ) +from homeassistant.helpers.condition import Condition from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import UNDEFINED from homeassistant.setup import async_setup_component @@ -48,9 +50,12 @@ from tests.common import ( MockConfigEntry, + MockModule, async_capture_events, async_fire_time_changed, async_mock_service, + mock_integration, + mock_platform, ) ENTITY_ID = "script.test" @@ -2289,7 +2294,7 @@ async def test_condition_created_once(async_from_config, hass: HomeAssistant) -> await hass.async_block_till_done() async_from_config.assert_called_once() - assert len(script_obj._config_cache) == 1 + assert len(script_obj._condition_cache) == 1 async def test_condition_all_cached(hass: HomeAssistant) -> None: @@ -2312,7 +2317,7 @@ async def test_condition_all_cached(hass: HomeAssistant) -> None: await script_obj.async_run(context=Context()) await hass.async_block_till_done() - assert len(script_obj._config_cache) == 2 + assert len(script_obj._condition_cache) == 2 @pytest.mark.parametrize("count", [3, script.ACTION_TRACE_NODE_MAX_LEN * 2]) @@ -6898,3 +6903,242 @@ async def test_enabled_sequence_in_parallel( ], } assert_action_trace(expected_trace) + + +async def _setup_mock_condition_integration(hass: HomeAssistant) -> None: + """Set up a mock integration with conditions that track async_unload calls.""" + + class MockCondition(Condition): + def __new__(cls, *args: Any, **kwargs: Any) -> Condition: + """Return a mock instance that tracks async_setup and async_unload calls.""" + mocked = mock.Mock(spec=Condition) + mocked.async_setup = AsyncMock() + mocked.async_unload = mock.Mock() + return mocked + + @classmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: dict[str, Any] + ) -> dict[str, Any]: + """Validate config.""" + return config + + def _async_check(self, **kwargs: Any) -> bool | None: + """Check the condition.""" + raise NotImplementedError + + async def async_get_conditions( + hass: HomeAssistant, + ) -> dict[str, type[Condition]]: + return {"_": MockCondition} + + mock_integration(hass, MockModule("test")) + mock_platform( + hass, "test.condition", mock.Mock(async_get_conditions=async_get_conditions) + ) + + +async def test_async_unload_clears_condition_cache(hass: HomeAssistant) -> None: + """Test that async_unload clears _condition_cache and unloads conditions.""" + await _setup_mock_condition_integration(hass) + sequence = cv.SCRIPT_SCHEMA( + [ + {"condition": "test"}, + {"event": "test_event"}, + ] + ) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + await script_obj.async_run(context=Context()) + await hass.async_block_till_done() + + assert len(script_obj._condition_cache) == 1 + cached_cond = next(iter(script_obj._condition_cache.values())) + + script_obj.async_unload() + + assert len(script_obj._condition_cache) == 0 + cached_cond.async_unload.assert_called_once() + + +async def test_async_unload_clears_repeat_scripts(hass: HomeAssistant) -> None: + """Test that async_unload unloads repeat sub-scripts.""" + sequence = cv.SCRIPT_SCHEMA( + [ + { + "repeat": { + "count": 1, + "sequence": [{"event": "test_event"}], + } + }, + ] + ) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + await script_obj.async_run(context=Context()) + await hass.async_block_till_done() + + assert len(script_obj._repeat_script) == 1 + sub_script = next(iter(script_obj._repeat_script.values())) + + with mock.patch.object(sub_script, "async_unload") as unload_mock: + script_obj.async_unload() + + assert len(script_obj._repeat_script) == 0 + unload_mock.assert_called_once() + + +async def test_async_unload_clears_choose_data(hass: HomeAssistant) -> None: + """Test that async_unload unloads choose sub-scripts.""" + sequence = cv.SCRIPT_SCHEMA( + [ + { + "choose": [ + { + "conditions": "{{ true }}", + "sequence": [{"event": "test_event"}], + } + ], + "default": [{"event": "default_event"}], + }, + ] + ) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + await script_obj.async_run(context=Context()) + await hass.async_block_till_done() + + assert len(script_obj._choose_data) == 1 + choose_data = next(iter(script_obj._choose_data.values())) + _choice_conditions, choice_script = choose_data["choices"][0] + default_script = choose_data["default"] + + with ( + mock.patch.object(choice_script, "async_unload") as choice_unload, + mock.patch.object(default_script, "async_unload") as default_unload, + ): + script_obj.async_unload() + + assert len(script_obj._choose_data) == 0 + choice_unload.assert_called_once() + default_unload.assert_called_once() + + +async def test_async_unload_clears_if_data(hass: HomeAssistant) -> None: + """Test that async_unload unloads if/then/else sub-scripts.""" + sequence = cv.SCRIPT_SCHEMA( + [ + { + "if": "{{ true }}", + "then": [{"event": "then_event"}], + "else": [{"event": "else_event"}], + }, + ] + ) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + await script_obj.async_run(context=Context()) + await hass.async_block_till_done() + + assert len(script_obj._if_data) == 1 + if_data = next(iter(script_obj._if_data.values())) + then_script = if_data["if_then"] + else_script = if_data["if_else"] + + with ( + mock.patch.object(then_script, "async_unload") as then_unload, + mock.patch.object(else_script, "async_unload") as else_unload, + ): + script_obj.async_unload() + + assert len(script_obj._if_data) == 0 + then_unload.assert_called_once() + else_unload.assert_called_once() + + +async def test_async_unload_clears_parallel_scripts(hass: HomeAssistant) -> None: + """Test that async_unload unloads parallel sub-scripts.""" + sequence = cv.SCRIPT_SCHEMA( + [ + { + "parallel": [ + {"sequence": [{"event": "test_event_1"}]}, + {"sequence": [{"event": "test_event_2"}]}, + ], + }, + ] + ) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + await script_obj.async_run(context=Context()) + await hass.async_block_till_done() + + assert len(script_obj._parallel_scripts) == 1 + parallel_scripts = next(iter(script_obj._parallel_scripts.values())) + assert len(parallel_scripts) == 2 + + with ( + mock.patch.object(parallel_scripts[0], "async_unload") as unload_0, + mock.patch.object(parallel_scripts[1], "async_unload") as unload_1, + ): + script_obj.async_unload() + + assert len(script_obj._parallel_scripts) == 0 + unload_0.assert_called_once() + unload_1.assert_called_once() + + +async def test_script_del_calls_async_unload(hass: HomeAssistant) -> None: + """Test that __del__ calls async_unload if not already called.""" + sequence = cv.SCRIPT_SCHEMA([{"event": "test_event"}]) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + unload_mock = mock.Mock(wraps=script_obj.async_unload) + script_obj.async_unload = unload_mock + + # Pylint says we should `del script_obj`. However, that's not guaranteed + # to immediately call __del__. + script_obj.__del__() # pylint: disable=unnecessary-dunder-call + unload_mock.assert_called_once() + + +async def test_script_del_skips_if_already_unloaded(hass: HomeAssistant) -> None: + """Test that __del__ does not call async_unload if already called.""" + sequence = cv.SCRIPT_SCHEMA([{"event": "test_event"}]) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + unload_mock = mock.Mock(wraps=script_obj.async_unload) + script_obj.async_unload = unload_mock + + # First call sets the flag + script_obj.async_unload() + unload_mock.assert_called_once() + unload_mock.reset_mock() + + # __del__ should skip since _unloaded is True + # Pylint says we should `del checker`. However, that's not guaranteed + # to immediately call __del__. + script_obj.__del__() # pylint: disable=unnecessary-dunder-call + unload_mock.assert_not_called() + + +async def test_async_unload_raises_if_running(hass: HomeAssistant) -> None: + """Test that async_unload raises RuntimeError if the script is running.""" + sequence = cv.SCRIPT_SCHEMA( + [ + {"wait_template": "{{ false }}"}, + ] + ) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + hass.async_create_task(script_obj.async_run(context=Context())) + await asyncio.sleep(0) + + assert script_obj.is_running + + with pytest.raises(RuntimeError, match="Cannot unload script"): + script_obj.async_unload() + + await script_obj.async_stop() + assert not script_obj.is_running + + # Should succeed now + script_obj.async_unload() diff --git a/tests/helpers/test_trigger_template_entity.py b/tests/helpers/test_trigger_template_entity.py index 08f6c7de8197e..0bbe521eef000 100644 --- a/tests/helpers/test_trigger_template_entity.py +++ b/tests/helpers/test_trigger_template_entity.py @@ -296,14 +296,20 @@ def some_other_key(self) -> dict[str, Any] | None: assert entity.some_other_key == {"test_key": "test_data"} +@pytest.mark.parametrize( + "device_class", + [SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME], +) async def test_manual_trigger_sensor_entity_with_date( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_class: SensorDeviceClass, ) -> None: """Test manual trigger template entity when availability template isn't used.""" config = { CONF_NAME: template.Template("test_entity", hass), CONF_STATE: template.Template("{{ as_datetime(value) }}", hass), - CONF_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, + CONF_DEVICE_CLASS: device_class, } class TestEntity(ManualTriggerSensorEntity): @@ -328,4 +334,4 @@ def state(self) -> bool | None: "2025-01-01T00:00:00+00:00", entity.entity_id, entity.device_class ) assert entity.state == "2025-01-01T00:00:00+00:00" - assert entity.device_class == SensorDeviceClass.TIMESTAMP + assert entity.device_class == device_class diff --git a/tests/snapshots/test_bootstrap.ambr b/tests/snapshots/test_bootstrap.ambr index 8e21e77d4f82b..e50a244d78952 100644 --- a/tests/snapshots/test_bootstrap.ambr +++ b/tests/snapshots/test_bootstrap.ambr @@ -75,6 +75,7 @@ 'onboarding', 'person', 'power', + 'radio_frequency', 'remote', 'repairs', 'scene', @@ -182,6 +183,7 @@ 'onboarding', 'person', 'power', + 'radio_frequency', 'remote', 'repairs', 'scene',