From 8e2c2c3cf5c533fa8bc38d2a5432831c7e244ad8 Mon Sep 17 00:00:00 2001 From: MandusBorjesson Date: Thu, 10 Sep 2026 17:48:14 +0200 Subject: [PATCH 01/21] Add Bitvis Power Hub integration (#165457) --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/bitvis/__init__.py | 56 + .../components/bitvis/config_flow.py | 235 +++ homeassistant/components/bitvis/const.py | 18 + .../components/bitvis/coordinator.py | 197 ++ homeassistant/components/bitvis/manifest.json | 12 + .../components/bitvis/quality_scale.yaml | 82 + homeassistant/components/bitvis/sensor.py | 501 +++++ homeassistant/components/bitvis/strings.json | 87 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + homeassistant/generated/zeroconf.py | 5 + mypy.ini | 10 + requirements_all.txt | 3 + tests/components/bitvis/__init__.py | 35 + tests/components/bitvis/conftest.py | 233 +++ .../bitvis/snapshots/test_sensor.ambr | 1721 +++++++++++++++++ tests/components/bitvis/test_config_flow.py | 613 ++++++ tests/components/bitvis/test_coordinator.py | 45 + tests/components/bitvis/test_init.py | 99 + tests/components/bitvis/test_sensor.py | 293 +++ 22 files changed, 4255 insertions(+) create mode 100644 homeassistant/components/bitvis/__init__.py create mode 100644 homeassistant/components/bitvis/config_flow.py create mode 100644 homeassistant/components/bitvis/const.py create mode 100644 homeassistant/components/bitvis/coordinator.py create mode 100644 homeassistant/components/bitvis/manifest.json create mode 100644 homeassistant/components/bitvis/quality_scale.yaml create mode 100644 homeassistant/components/bitvis/sensor.py create mode 100644 homeassistant/components/bitvis/strings.json create mode 100644 tests/components/bitvis/__init__.py create mode 100644 tests/components/bitvis/conftest.py create mode 100644 tests/components/bitvis/snapshots/test_sensor.ambr create mode 100644 tests/components/bitvis/test_config_flow.py create mode 100644 tests/components/bitvis/test_coordinator.py create mode 100644 tests/components/bitvis/test_init.py create mode 100644 tests/components/bitvis/test_sensor.py diff --git a/.strict-typing b/.strict-typing index d5e01d6ea7ff52..b4f3b14ff01f07 100644 --- a/.strict-typing +++ b/.strict-typing @@ -118,6 +118,7 @@ homeassistant.components.bayesian.* homeassistant.components.besen.* homeassistant.components.binary_sensor.* homeassistant.components.bitcoin.* +homeassistant.components.bitvis.* homeassistant.components.blockchain.* homeassistant.components.blue_current.* homeassistant.components.blueprint.* diff --git a/CODEOWNERS b/CODEOWNERS index c7a1b38d075a79..b627d73220ba03 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -233,6 +233,8 @@ CLAUDE.md @home-assistant/core /tests/components/besen/ @moryoav /homeassistant/components/binary_sensor/ @home-assistant/core /tests/components/binary_sensor/ @home-assistant/core +/homeassistant/components/bitvis/ @MandusBorjesson @real-tintin @simontegelid +/tests/components/bitvis/ @MandusBorjesson @real-tintin @simontegelid /homeassistant/components/bizkaibus/ @UgaitzEtxebarria /homeassistant/components/blebox/ @bbx-a @swistakm @bkobus-bbx /tests/components/blebox/ @bbx-a @swistakm @bkobus-bbx diff --git a/homeassistant/components/bitvis/__init__.py b/homeassistant/components/bitvis/__init__.py new file mode 100644 index 00000000000000..cb1f689a0d73f4 --- /dev/null +++ b/homeassistant/components/bitvis/__init__.py @@ -0,0 +1,56 @@ +"""The Bitvis Power Hub integration.""" + +from typing import TYPE_CHECKING + +from homeassistant.const import CONF_PORT, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from .const import DATA_LISTENER_REGISTRY, DOMAIN +from .coordinator import ( + BitvisConfigEntry, + BitvisDataUpdateCoordinator, + async_get_listener_registry, +) + +_PLATFORMS: list[Platform] = [Platform.SENSOR] + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Bitvis Power Hub integration.""" + async_get_listener_registry(hass) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: BitvisConfigEntry) -> bool: + """Set up Bitvis Power Hub from a config entry.""" + async_get_listener_registry(hass) + if TYPE_CHECKING: + assert entry.unique_id is not None + coordinator = BitvisDataUpdateCoordinator( + hass, + entry, + entry.data[CONF_PORT], + entry.unique_id, + ) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: BitvisConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) + if unload_ok: + await entry.runtime_data.async_stop() + if not hass.config_entries.async_loaded_entries(DOMAIN): + hass.data.pop(DATA_LISTENER_REGISTRY, None) + return unload_ok diff --git a/homeassistant/components/bitvis/config_flow.py b/homeassistant/components/bitvis/config_flow.py new file mode 100644 index 00000000000000..21f8aff7e324cc --- /dev/null +++ b/homeassistant/components/bitvis/config_flow.py @@ -0,0 +1,235 @@ +"""Config flow for the Bitvis Power Hub integration.""" + +import asyncio +import logging +from typing import Any, Self, override + +from bitvis_protobuf.listener import FilterIp +from bitvis_protobuf.parse import PayloadDiagnostic, PayloadSample +from bitvis_protobuf.utils import ( + InvalidMacAddressError, + async_resolve_host, + async_verify_udp_port_bindable, + normalize_host, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from .const import DEFAULT_NAME, DEFAULT_PORT, DISCOVERY_TIMEOUT, DOMAIN +from .coordinator import async_get_listener_registry + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + } +) + + +def _get_friendly_name(name: str | None) -> str: + """Return a user-friendly name derived from the zeroconf name.""" + if not name: + return DEFAULT_NAME + instance = name.split(".", 1)[0] + return instance or DEFAULT_NAME + + +async def _async_test_port(hass: HomeAssistant, port: int) -> None: + """Verify the UDP port can be bound.""" + + if async_get_listener_registry(hass).has_listener(port): + return + + await async_verify_udp_port_bindable(port) + + +async def _async_discover_mac_address(hass: HomeAssistant, host: str, port: int) -> str: + """Wait for a UDP message from the device and return its MAC address.""" + resolved_ips = await async_resolve_host(host) + listener_registry = async_get_listener_registry(hass) + listener = await listener_registry.async_get_or_create(port) + + loop = asyncio.get_running_loop() + future: asyncio.Future[str] = loop.create_future() + + @callback + def _on_payload( + payload: PayloadSample | PayloadDiagnostic, _addr: tuple[str, int] + ) -> None: + if not future.done(): + future.set_result(payload.mac_address) + + @callback + def _on_error(err: Exception, addr: tuple[str, int]) -> None: + if ( + addr[0] in resolved_ips + and not future.done() + and isinstance(err, InvalidMacAddressError) + ): + future.set_exception(err) + + filters: list[FilterIp] = [] + listener.register_error_callback(_on_error) + try: + for ip in resolved_ips: + filt = FilterIp(ip) + try: + listener.register(filt, _on_payload) + except RuntimeError as err: + raise AbortFlow("already_in_progress") from err + filters.append(filt) + + return await asyncio.wait_for(future, timeout=DISCOVERY_TIMEOUT) + finally: + listener.unregister_error_callback(_on_error) + for filt in filters: + listener.unregister(filt) + await listener_registry.async_remove_if_unused(port) + + +class BitvisConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Bitvis Power Hub.""" + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovery_info: ZeroconfServiceInfo | None = None + self._validated_host: str | None = None + self._host: str | None = None + + @override + def is_matching(self, other_flow: Self) -> bool: + """Return True if other_flow is matching this flow.""" + return self._host is not None and self._host == other_flow._host + + async def _async_validate_host(self, host: str) -> str: + """Verify port availability and discover the device MAC address.""" + await _async_test_port(self.hass, DEFAULT_PORT) + return await _async_discover_mac_address(self.hass, host, DEFAULT_PORT) + + async def _async_create_entry_from_host( + self, host: str, title: str + ) -> ConfigFlowResult: + """Validate connectivity, discover MAC address, and create the entry.""" + self._host = host + if self.hass.config_entries.flow.async_has_matching_flow(self): + return self.async_abort(reason="already_in_progress") + + try: + mac_address = await self._async_validate_host(host) + except TimeoutError: + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, {CONF_HOST: host} + ), + errors={"base": "timeout_connect"}, + ) + except InvalidMacAddressError: + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, {CONF_HOST: host} + ), + errors={"base": "invalid_mac"}, + ) + except OSError: + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, {CONF_HOST: host} + ), + errors={"base": "cannot_connect"}, + ) + + await self.async_set_unique_id(format_mac(mac_address)) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=title, + data={ + CONF_HOST: host, + CONF_PORT: DEFAULT_PORT, + }, + ) + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + if user_input is not None: + host = normalize_host(user_input[CONF_HOST]) + return await self._async_create_entry_from_host(host, DEFAULT_NAME) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + ) + + @override + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + _LOGGER.debug("Discovered Bitvis Power Hub via Zeroconf: %s", discovery_info) + + host = discovery_info.host + self._host = host + + if self.hass.config_entries.flow.async_has_matching_flow(self): + return self.async_abort(reason="already_in_progress") + + try: + mac_address = await self._async_validate_host(host) + except TimeoutError: + return self.async_abort(reason="timeout_connect") + except InvalidMacAddressError: + return self.async_abort(reason="invalid_mac") + except OSError: + return self.async_abort(reason="cannot_connect") + + await self.async_set_unique_id(format_mac(mac_address)) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + + self._discovery_info = discovery_info + self._validated_host = host + + self.context["title_placeholders"] = { + "name": _get_friendly_name(discovery_info.name), + "host": host, + } + + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + assert self._discovery_info is not None + + if user_input is not None: + assert self._validated_host is not None + + return self.async_create_entry( + title=_get_friendly_name(self._discovery_info.name), + data={ + CONF_HOST: self._validated_host, + CONF_PORT: DEFAULT_PORT, + }, + ) + + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={ + "name": _get_friendly_name(self._discovery_info.name), + "host": self._discovery_info.host, + }, + ) diff --git a/homeassistant/components/bitvis/const.py b/homeassistant/components/bitvis/const.py new file mode 100644 index 00000000000000..c1bdd6d85e4249 --- /dev/null +++ b/homeassistant/components/bitvis/const.py @@ -0,0 +1,18 @@ +"""Constants for the Bitvis Power Hub integration.""" + +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from .coordinator import BitvisListenerRegistry + +DOMAIN = "bitvis" +MANUFACTURER = "Bitvis" +MODEL_NAME = "Power Hub" + +DEFAULT_NAME = "Bitvis Power Hub" +DEFAULT_PORT = 58220 +DISCOVERY_TIMEOUT = 30 + +DATA_LISTENER_REGISTRY: HassKey[BitvisListenerRegistry] = HassKey(DOMAIN) diff --git a/homeassistant/components/bitvis/coordinator.py b/homeassistant/components/bitvis/coordinator.py new file mode 100644 index 00000000000000..06ef13592fd69f --- /dev/null +++ b/homeassistant/components/bitvis/coordinator.py @@ -0,0 +1,197 @@ +"""Data coordinator for Bitvis Power Hub.""" + +import asyncio +from dataclasses import dataclass +from datetime import datetime, timedelta +import logging +from typing import override + +from bitvis_protobuf.listener import FilterMac, SharedListener +from bitvis_protobuf.parse import PayloadDiagnostic, PayloadSample + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util +from homeassistant.util.variance import ignore_variance + +from .const import DATA_LISTENER_REGISTRY, DOMAIN, MODEL_NAME + +_LOGGER = logging.getLogger(__name__) + +type BitvisConfigEntry = ConfigEntry[BitvisDataUpdateCoordinator] + + +def _uptime_to_boot_time(uptime_s: int) -> datetime: + """Convert uptime in seconds to an absolute boot datetime.""" + return dt_util.utcnow().replace(microsecond=0) - timedelta(seconds=uptime_s) + + +@dataclass(kw_only=True) +class BitvisData: + """Data structure for Bitvis measurements.""" + + sample: PayloadSample | None = None + diagnostic: PayloadDiagnostic | None = None + boot_time: datetime | None = None + + +class BitvisListenerRegistry: + """Registry that manages one shared UDP listener per port. + + Stored at hass.data[DATA_LISTENER_REGISTRY] so all coordinators can + look it up without duplicating state-management logic. + """ + + def __init__(self) -> None: + """Initialize registry storage.""" + self._listeners: dict[int, SharedListener] = {} + self._locks: dict[int, asyncio.Lock] = {} + + async def async_get_or_create(self, port: int) -> SharedListener: + """Return the listener for *port*, creating and starting it if needed.""" + port_lock = self._locks.setdefault(port, asyncio.Lock()) + async with port_lock: + if port not in self._listeners: + listener = SharedListener() + await listener.start(port) + self._listeners[port] = listener + return self._listeners[port] + + async def async_remove_if_unused(self, port: int) -> None: + """Stop and remove the listener for *port* when no coordinators remain.""" + port_lock = self._locks.setdefault(port, asyncio.Lock()) + async with port_lock: + listener = self._listeners.get(port) + if listener is None or not listener.is_empty: + return + await listener.stop() + del self._listeners[port] + + def get(self, port: int) -> SharedListener | None: + """Return an existing listener for *port*, or None.""" + return self._listeners.get(port) + + def has_listener(self, port: int) -> bool: + """Return True if a listener is already active on *port*.""" + return port in self._listeners + + +def async_get_listener_registry(hass: HomeAssistant) -> BitvisListenerRegistry: + """Return (creating if needed) the Bitvis listener registry for this HA instance.""" + if DATA_LISTENER_REGISTRY not in hass.data: + hass.data[DATA_LISTENER_REGISTRY] = BitvisListenerRegistry() + return hass.data[DATA_LISTENER_REGISTRY] + + +class BitvisDataUpdateCoordinator(DataUpdateCoordinator[BitvisData]): + """Coordinator to manage data updates from UDP packets.""" + + config_entry: BitvisConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: BitvisConfigEntry, + port: int, + mac_address: str, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + config_entry=config_entry, + ) + self.port = port + self.mac_address = mac_address + self._filter = FilterMac(mac_address) + self._registered = False + self._stable_boot_time = ignore_variance( + _uptime_to_boot_time, timedelta(minutes=5) + ) + self.data = BitvisData() + + @override + async def _async_setup(self) -> None: + """Set up the coordinator by registering with the shared UDP listener.""" + try: + listener = await self.hass.data[DATA_LISTENER_REGISTRY].async_get_or_create( + self.port + ) + listener.register(self._filter, self._handle_payload) + self._registered = True + except OSError as err: + raise UpdateFailed( + f"Failed to start UDP listener on port {self.port}" + ) from err + except RuntimeError as err: + raise ConfigEntryError( + f"Failed to register MAC filter for {self.mac_address} " + f"on port {self.port}" + ) from err + + async def async_stop(self) -> None: + """Unregister from the shared listener, stopping it when no longer needed.""" + if not self._registered: + return + + listener_registry = self.hass.data[DATA_LISTENER_REGISTRY] + if listener := listener_registry.get(self.port): + listener.unregister(self._filter) + await listener_registry.async_remove_if_unused(self.port) + + self._registered = False + _LOGGER.debug( + "Unregistered coordinator from shared UDP listener for port %s", self.port + ) + + @callback + def _handle_payload( + self, + payload: PayloadSample | PayloadDiagnostic, + addr: tuple[str, int], + ) -> None: + """Handle a parsed payload dispatched by the shared listener.""" + _LOGGER.debug("Received payload from %s", addr) + if isinstance(payload, PayloadSample): + self._handle_sample(payload) + else: + self._handle_diagnostic(payload) + + @callback + def _handle_sample(self, payload: PayloadSample) -> None: + """Update sample data and notify listeners.""" + self.data.sample = payload + self.async_set_updated_data(self.data) + + @callback + def _handle_diagnostic(self, payload: PayloadDiagnostic) -> None: + """Update diagnostic data and notify listeners.""" + self.data.diagnostic = payload + diagnostic = payload.diagnostic + self.data.boot_time = self._stable_boot_time(diagnostic.uptime_s) + + if diagnostic.HasField("device_info"): + device_reg = dr.async_get(self.hass) + if device := device_reg.async_get_device_by_identifier( + (DOMAIN, self.mac_address), self.config_entry.entry_id + ): + device_info = diagnostic.device_info + model = device_info.model_name or MODEL_NAME + sw_version = device_info.sw_version or None + if device.model != model or device.sw_version != sw_version: + device_reg.async_update_device( + device.id, + model=model, + sw_version=sw_version, + ) + + self.async_set_updated_data(self.data) + + @override + async def _async_update_data(self) -> BitvisData: + """Return current data (updates are push-based via UDP datagrams).""" + return self.data diff --git a/homeassistant/components/bitvis/manifest.json b/homeassistant/components/bitvis/manifest.json new file mode 100644 index 00000000000000..59012b8b318b4d --- /dev/null +++ b/homeassistant/components/bitvis/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "bitvis", + "name": "Bitvis Power Hub", + "codeowners": ["@MandusBorjesson", "@real-tintin", "@simontegelid"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/bitvis", + "integration_type": "device", + "iot_class": "local_push", + "quality_scale": "bronze", + "requirements": ["bitvis-protobuf==2.0.4"], + "zeroconf": ["_powerhub._udp.local."] +} diff --git a/homeassistant/components/bitvis/quality_scale.yaml b/homeassistant/components/bitvis/quality_scale.yaml new file mode 100644 index 00000000000000..6e28d51252ec44 --- /dev/null +++ b/homeassistant/components/bitvis/quality_scale.yaml @@ -0,0 +1,82 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: "The integration does not have any actions." + appropriate-polling: + status: exempt + comment: "The integration does not poll." + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: "The integration does not have actions." + docs-conditions: + status: exempt + comment: "The integration does not have conditions." + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: "The integration does not have triggers." + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: "The integration does not have any service actions." + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: "The integration has no options flow." + docs-installation-parameters: done + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: done + reauthentication-flow: + status: exempt + comment: "The integration does not use any credentials. It connects to a local device using only host and port via unauthenticated UDP push." + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: "The integration connects to a single device." + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: "The integration does not use HTTP." + strict-typing: done diff --git a/homeassistant/components/bitvis/sensor.py b/homeassistant/components/bitvis/sensor.py new file mode 100644 index 00000000000000..299544885ce71b --- /dev/null +++ b/homeassistant/components/bitvis/sensor.py @@ -0,0 +1,501 @@ +"""Sensor platform for Bitvis Power Hub.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from typing import TYPE_CHECKING, cast, override + +from bitvis_protobuf.han_port_pb2 import HanPortSample +from bitvis_protobuf.powerhub_pb2 import Diagnostic + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, + UnitOfReactiveEnergy, + UnitOfReactivePower, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import BitvisConfigEntry +from .const import DOMAIN, MANUFACTURER +from .coordinator import BitvisDataUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +def _optional(field: str) -> Callable[[HanPortSample], float | None]: + """Return a getter that yields None when a protobuf field is unset.""" + + def _get(data: HanPortSample) -> float | None: + if data.HasField(field): + return cast(float, getattr(data, field)) + return None + + return _get + + +@dataclass(frozen=True, kw_only=True) +class BitvisSensorEntityDescription(SensorEntityDescription): + """Describes Bitvis sensor entity.""" + + value_fn: Callable[[HanPortSample], float | None] + + +@dataclass(frozen=True, kw_only=True) +class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): + """Describes Bitvis diagnostic sensor entity.""" + + value_fn: Callable[[Diagnostic], float | int | str | datetime | None] + + +SENSOR_DESCRIPTIONS: tuple[BitvisSensorEntityDescription, ...] = ( + # Phase voltages + BitvisSensorEntityDescription( + key="phase_voltage_l1", + translation_key="phase_voltage", + translation_placeholders={"phase": "L1"}, + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + entity_registry_enabled_default=False, + value_fn=_optional("phase_voltage_l1_v"), + ), + BitvisSensorEntityDescription( + key="phase_voltage_l2", + translation_key="phase_voltage", + translation_placeholders={"phase": "L2"}, + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + entity_registry_enabled_default=False, + value_fn=_optional("phase_voltage_l2_v"), + ), + BitvisSensorEntityDescription( + key="phase_voltage_l3", + translation_key="phase_voltage", + translation_placeholders={"phase": "L3"}, + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + entity_registry_enabled_default=False, + value_fn=_optional("phase_voltage_l3_v"), + ), + # Phase currents + BitvisSensorEntityDescription( + key="phase_current_l1", + translation_key="phase_current", + translation_placeholders={"phase": "L1"}, + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + value_fn=_optional("phase_current_l1_a"), + ), + BitvisSensorEntityDescription( + key="phase_current_l2", + translation_key="phase_current", + translation_placeholders={"phase": "L2"}, + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + value_fn=_optional("phase_current_l2_a"), + ), + BitvisSensorEntityDescription( + key="phase_current_l3", + translation_key="phase_current", + translation_placeholders={"phase": "L3"}, + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + value_fn=_optional("phase_current_l3_a"), + ), + # Total active power + BitvisSensorEntityDescription( + key="power_active_delivered_to_client", + translation_key="power_active_import", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + value_fn=_optional("power_active_delivered_to_client_kw"), + ), + BitvisSensorEntityDescription( + key="power_active_delivered_by_client", + translation_key="power_active_export", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + value_fn=_optional("power_active_delivered_by_client_kw"), + ), + # Total reactive power + BitvisSensorEntityDescription( + key="power_reactive_delivered_to_client", + translation_key="power_reactive_import", + device_class=SensorDeviceClass.REACTIVE_POWER, + native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + entity_registry_enabled_default=False, + value_fn=_optional("power_reactive_delivered_to_client_kvar"), + ), + BitvisSensorEntityDescription( + key="power_reactive_delivered_by_client", + translation_key="power_reactive_export", + device_class=SensorDeviceClass.REACTIVE_POWER, + native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + entity_registry_enabled_default=False, + value_fn=_optional("power_reactive_delivered_by_client_kvar"), + ), + # Per-phase active power (to client) + BitvisSensorEntityDescription( + key="power_active_l1_delivered_to_client", + translation_key="power_active_phase_import", + translation_placeholders={"phase": "L1"}, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + entity_registry_enabled_default=False, + value_fn=_optional("power_active_l1_delivered_to_client_kw"), + ), + BitvisSensorEntityDescription( + key="power_active_l2_delivered_to_client", + translation_key="power_active_phase_import", + translation_placeholders={"phase": "L2"}, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + entity_registry_enabled_default=False, + value_fn=_optional("power_active_l2_delivered_to_client_kw"), + ), + BitvisSensorEntityDescription( + key="power_active_l3_delivered_to_client", + translation_key="power_active_phase_import", + translation_placeholders={"phase": "L3"}, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + entity_registry_enabled_default=False, + value_fn=_optional("power_active_l3_delivered_to_client_kw"), + ), + # Per-phase active power (by client) + BitvisSensorEntityDescription( + key="power_active_l1_delivered_by_client", + translation_key="power_active_phase_export", + translation_placeholders={"phase": "L1"}, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + entity_registry_enabled_default=False, + value_fn=_optional("power_active_l1_delivered_by_client_kw"), + ), + BitvisSensorEntityDescription( + key="power_active_l2_delivered_by_client", + translation_key="power_active_phase_export", + translation_placeholders={"phase": "L2"}, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + entity_registry_enabled_default=False, + value_fn=_optional("power_active_l2_delivered_by_client_kw"), + ), + BitvisSensorEntityDescription( + key="power_active_l3_delivered_by_client", + translation_key="power_active_phase_export", + translation_placeholders={"phase": "L3"}, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + entity_registry_enabled_default=False, + value_fn=_optional("power_active_l3_delivered_by_client_kw"), + ), + # Per-phase reactive power (to client) + BitvisSensorEntityDescription( + key="power_reactive_l1_delivered_to_client", + translation_key="power_reactive_phase_import", + translation_placeholders={"phase": "L1"}, + device_class=SensorDeviceClass.REACTIVE_POWER, + native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + entity_registry_enabled_default=False, + value_fn=_optional("power_reactive_l1_delivered_to_client_kvar"), + ), + BitvisSensorEntityDescription( + key="power_reactive_l2_delivered_to_client", + translation_key="power_reactive_phase_import", + translation_placeholders={"phase": "L2"}, + device_class=SensorDeviceClass.REACTIVE_POWER, + native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + entity_registry_enabled_default=False, + value_fn=_optional("power_reactive_l2_delivered_to_client_kvar"), + ), + BitvisSensorEntityDescription( + key="power_reactive_l3_delivered_to_client", + translation_key="power_reactive_phase_import", + translation_placeholders={"phase": "L3"}, + device_class=SensorDeviceClass.REACTIVE_POWER, + native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + entity_registry_enabled_default=False, + value_fn=_optional("power_reactive_l3_delivered_to_client_kvar"), + ), + # Per-phase reactive power (by client) + BitvisSensorEntityDescription( + key="power_reactive_l1_delivered_by_client", + translation_key="power_reactive_phase_export", + translation_placeholders={"phase": "L1"}, + device_class=SensorDeviceClass.REACTIVE_POWER, + native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + entity_registry_enabled_default=False, + value_fn=_optional("power_reactive_l1_delivered_by_client_kvar"), + ), + BitvisSensorEntityDescription( + key="power_reactive_l2_delivered_by_client", + translation_key="power_reactive_phase_export", + translation_placeholders={"phase": "L2"}, + device_class=SensorDeviceClass.REACTIVE_POWER, + native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + entity_registry_enabled_default=False, + value_fn=_optional("power_reactive_l2_delivered_by_client_kvar"), + ), + BitvisSensorEntityDescription( + key="power_reactive_l3_delivered_by_client", + translation_key="power_reactive_phase_export", + translation_placeholders={"phase": "L3"}, + device_class=SensorDeviceClass.REACTIVE_POWER, + native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + entity_registry_enabled_default=False, + value_fn=_optional("power_reactive_l3_delivered_by_client_kvar"), + ), + # Energy - active + BitvisSensorEntityDescription( + key="energy_active_delivered_to_client", + translation_key="energy_active_import", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + value_fn=_optional("energy_active_delivered_to_client_kwh"), + ), + BitvisSensorEntityDescription( + key="energy_active_delivered_by_client", + translation_key="energy_active_export", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + value_fn=_optional("energy_active_delivered_by_client_kwh"), + ), + # Energy - reactive + BitvisSensorEntityDescription( + key="energy_reactive_delivered_to_client", + translation_key="energy_reactive_import", + device_class=SensorDeviceClass.REACTIVE_ENERGY, + native_unit_of_measurement=UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + entity_registry_enabled_default=False, + value_fn=_optional("energy_reactive_delivered_to_client_kvarh"), + ), + BitvisSensorEntityDescription( + key="energy_reactive_delivered_by_client", + translation_key="energy_reactive_export", + device_class=SensorDeviceClass.REACTIVE_ENERGY, + native_unit_of_measurement=UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + entity_registry_enabled_default=False, + value_fn=_optional("energy_reactive_delivered_by_client_kvarh"), + ), +) + +UPTIME_DESCRIPTION = SensorEntityDescription( + key="uptime", + device_class=SensorDeviceClass.UPTIME, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, +) + +DIAGNOSTIC_SENSOR_DESCRIPTIONS: tuple[BitvisDiagnosticSensorEntityDescription, ...] = ( + BitvisDiagnosticSensorEntityDescription( + key="wifi_rssi", + translation_key="wifi_rssi", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.wifi_rssi_dbm, + ), + BitvisDiagnosticSensorEntityDescription( + key="han_msg_successfully_parsed", + translation_key="han_msg_successfully_parsed", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.han_msg_successfully_parsed, + ), + BitvisDiagnosticSensorEntityDescription( + key="han_msg_buffer_overflow", + translation_key="han_msg_buffer_overflow", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.han_msg_buffer_overflow, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BitvisConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Bitvis sensor platform.""" + coordinator = entry.runtime_data + known_keys: set[str] = set() + + async_add_entities( + [ + BitvisUptimeSensorEntity(coordinator, UPTIME_DESCRIPTION), + *( + BitvisDiagnosticSensorEntity(coordinator, description) + for description in DIAGNOSTIC_SENSOR_DESCRIPTIONS + ), + ] + ) + + @callback + def _check_entities() -> None: + if (payload := coordinator.data.sample) is None: + return + entities = [ + BitvisSensorEntity(coordinator, description) + for description in SENSOR_DESCRIPTIONS + if description.key not in known_keys + and description.value_fn(payload.sample) is not None + ] + if entities: + known_keys.update(entity.entity_description.key for entity in entities) + async_add_entities(entities) + + _check_entities() + entry.async_on_unload(coordinator.async_add_listener(_check_entities)) + + +class BitvisBaseSensorEntity( + CoordinatorEntity[BitvisDataUpdateCoordinator], SensorEntity +): + """Base class for Bitvis sensor entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: BitvisDataUpdateCoordinator, + description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + mac_address = coordinator.mac_address + self._attr_unique_id = f"{mac_address}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, mac_address)}, + connections={(CONNECTION_NETWORK_MAC, mac_address)}, + manufacturer=MANUFACTURER, + ) + + +class BitvisSensorEntity(BitvisBaseSensorEntity): + """Representation of a Bitvis sensor.""" + + entity_description: BitvisSensorEntityDescription + + @property + @override + def native_value(self) -> float | None: + """Return the state of the sensor.""" + payload = self.coordinator.data.sample + if TYPE_CHECKING: + assert payload is not None + return self.entity_description.value_fn(payload.sample) + + @property + @override + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.coordinator.data.sample is not None + + +class BitvisDiagnosticSensorEntity(BitvisBaseSensorEntity): + """Representation of a Bitvis diagnostic sensor.""" + + entity_description: BitvisDiagnosticSensorEntityDescription + + @property + @override + def native_value(self) -> float | int | str | datetime | None: + """Return the state of the sensor.""" + payload = self.coordinator.data.diagnostic + if TYPE_CHECKING: + assert payload is not None + return self.entity_description.value_fn(payload.diagnostic) + + @property + @override + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.coordinator.data.diagnostic is not None + + +class BitvisUptimeSensorEntity(BitvisBaseSensorEntity): + """Sensor entity for device uptime (boot time).""" + + @property + @override + def native_value(self) -> datetime | None: + """Return the stable boot time computed by the coordinator.""" + return self.coordinator.data.boot_time + + @property + @override + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.coordinator.data.boot_time is not None diff --git a/homeassistant/components/bitvis/strings.json b/homeassistant/components/bitvis/strings.json new file mode 100644 index 00000000000000..f8f68fcb6e9ac4 --- /dev/null +++ b/homeassistant/components/bitvis/strings.json @@ -0,0 +1,87 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_mac": "The device did not provide a valid MAC address. Update the Power Hub to the latest firmware and try again.", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_mac": "The device did not provide a valid MAC address. Update the Power Hub to the latest firmware and try again.", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Bitvis Power Hub device." + }, + "description": "Enter the hostname or IP address of your Bitvis Power Hub. Setup waits for a UDP packet from the device.", + "title": "Set up Bitvis Power Hub" + }, + "zeroconf_confirm": { + "description": "Do you want to add the discovered Bitvis Power Hub ({name}) at {host} to Home Assistant?", + "title": "Discovered Bitvis Power Hub" + } + } + }, + "entity": { + "sensor": { + "energy_active_export": { + "name": "Active energy export" + }, + "energy_active_import": { + "name": "Active energy import" + }, + "energy_reactive_export": { + "name": "Reactive energy export" + }, + "energy_reactive_import": { + "name": "Reactive energy import" + }, + "han_msg_buffer_overflow": { + "name": "HAN buffer overflows" + }, + "han_msg_successfully_parsed": { + "name": "HAN messages successfully parsed" + }, + "phase_current": { + "name": "Current {phase}" + }, + "phase_voltage": { + "name": "Voltage {phase}" + }, + "power_active_export": { + "name": "Active power export" + }, + "power_active_import": { + "name": "Active power import" + }, + "power_active_phase_export": { + "name": "Active power export {phase}" + }, + "power_active_phase_import": { + "name": "Active power import {phase}" + }, + "power_reactive_export": { + "name": "Reactive power export" + }, + "power_reactive_import": { + "name": "Reactive power import" + }, + "power_reactive_phase_export": { + "name": "Reactive power export {phase}" + }, + "power_reactive_phase_import": { + "name": "Reactive power import {phase}" + }, + "wifi_rssi": { + "name": "Wi-Fi signal strength" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index dec26af9bfff86..26f525c1a545d1 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -102,6 +102,7 @@ "bang_olufsen", "bayesian", "besen", + "bitvis", "blebox", "blink", "blue_current", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 190556a56d3ff6..cf9ab63316b623 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -756,6 +756,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "bitvis": { + "name": "Bitvis Power Hub", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "bizkaibus": { "name": "Bizkaibus", "integration_type": "hub", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 7b08acf0ad4085..2d013ca31d52ce 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -888,6 +888,11 @@ "domain": "plugwise", }, ], + "_powerhub._udp.local.": [ + { + "domain": "bitvis", + }, + ], "_powerview._tcp.local.": [ { "domain": "hunterdouglas_powerview", diff --git a/mypy.ini b/mypy.ini index f4e044aa492560..998c3a6c576710 100644 --- a/mypy.ini +++ b/mypy.ini @@ -937,6 +937,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.bitvis.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.blockchain.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 1b13211612549e..71cb729f865074 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -669,6 +669,9 @@ beautifulsoup4==4.13.3 # homeassistant.components.besen besen==0.4.0 +# homeassistant.components.bitvis +bitvis-protobuf==2.0.4 + # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/tests/components/bitvis/__init__.py b/tests/components/bitvis/__init__.py new file mode 100644 index 00000000000000..0f84a7803690a5 --- /dev/null +++ b/tests/components/bitvis/__init__.py @@ -0,0 +1,35 @@ +"""Tests for the Bitvis Power Hub integration.""" + +from collections.abc import Callable + +from bitvis_protobuf.listener import FilterMac +from bitvis_protobuf.parse import PayloadDiagnostic, PayloadSample +import pytest + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the integration.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +def find_listener_callback( + listener: object, + mac_address: str, +) -> Callable[[PayloadSample | PayloadDiagnostic, tuple[str, int]], None]: + """Find the listener callback registered for a MAC address.""" + register = listener.register + for call in register.call_args_list: + filt = call[0][0] + if ( + isinstance(filt, FilterMac) + and filt.mac_address.lower() == mac_address.lower() + ): + return call[0][1] + pytest.fail(f"Callback for MAC {mac_address} not found") diff --git a/tests/components/bitvis/conftest.py b/tests/components/bitvis/conftest.py new file mode 100644 index 00000000000000..f62bcab053f964 --- /dev/null +++ b/tests/components/bitvis/conftest.py @@ -0,0 +1,233 @@ +"""Common fixtures for Bitvis Power Hub tests.""" + +from collections.abc import Callable, Generator, Iterator +from contextlib import ExitStack, contextmanager +from unittest.mock import AsyncMock, MagicMock, patch + +from bitvis_protobuf.listener import Filter, FilterIp +from bitvis_protobuf.parse import PayloadDiagnostic, PayloadSample, parse_payload +from bitvis_protobuf.powerhub_pb2 import Payload +from bitvis_protobuf.utils import InvalidMacAddressError +import pytest + +from homeassistant.components.bitvis.const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + +TEST_DEVICE_MAC = "aa:bb:cc:dd:ee:ff" +SECOND_DEVICE_MAC = "11:22:33:44:55:66" + +type ListenerCallback = Callable[ + [PayloadSample | PayloadDiagnostic, tuple[str, int]], None +] +type ErrorCallback = Callable[[Exception, tuple[str, int]], None] + + +class FakeListener: + """In-memory SharedListener stand-in for config-flow and coordinator tests.""" + + def __init__(self) -> None: + """Initialize callback storage and async start/stop mocks.""" + self._callbacks: dict[Filter, ListenerCallback] = {} + self._error_callbacks: list[ErrorCallback] = [] + self.start = AsyncMock() + self.stop = AsyncMock() + self.register = MagicMock(side_effect=self._register) + self.unregister = MagicMock(side_effect=self._unregister) + self.register_error_callback = MagicMock( + side_effect=self._error_callbacks.append + ) + self.unregister_error_callback = MagicMock(side_effect=self._unregister_error) + self.dispatch = MagicMock(side_effect=self._dispatch) + + @property + def is_empty(self) -> bool: + """Return True when no payload callbacks are registered.""" + return not self._callbacks + + def _register(self, filt: Filter, callback: ListenerCallback) -> None: + if filt in self._callbacks: + raise RuntimeError(f"Filter already registered: {filt}") + self._callbacks[filt] = callback + + def _unregister(self, filt: Filter) -> None: + self._callbacks.pop(filt, None) + + def _unregister_error(self, callback: ErrorCallback) -> None: + if callback in self._error_callbacks: + self._error_callbacks.remove(callback) + + def _dispatch(self, data: bytes, addr: tuple[str, int]) -> None: + try: + payload = parse_payload(data) + except InvalidMacAddressError as err: + for callback in self._error_callbacks: + callback(err, addr) + return + if payload is None: + return + self.deliver(payload, addr) + + def deliver( + self, + payload: PayloadSample | PayloadDiagnostic, + addr: tuple[str, int], + ) -> None: + """Deliver an already-parsed payload to matching callbacks.""" + host = addr[0] + for filt, callback in self._callbacks.items(): + if filt.match(payload, host): + callback(payload, addr) + + +@contextmanager +def patch_config_flow_connectivity( + resolved_host: str, + *, + mac_address: str = TEST_DEVICE_MAC, + deliver_mac: bool = True, + invalid_mac: bool = False, + port_bind_side_effect: BaseException | None = None, + discovery_timeout: bool = False, + register_side_effect: BaseException | None = None, + shared_listener: FakeListener | None = None, +) -> Iterator[AsyncMock]: + """Patch library connectivity helpers used by the config flow.""" + listener = shared_listener or FakeListener() + + if register_side_effect is not None: + listener.register.side_effect = register_side_effect + else: + original_register = listener.register.side_effect + + def _on_register(filt: Filter, callback: ListenerCallback) -> None: + original_register(filt, callback) + if not isinstance(filt, FilterIp): + return + if invalid_mac: + payload = Payload() + payload.sample.SetInParent() + listener.dispatch(payload.SerializeToString(), (resolved_host, 1234)) + elif deliver_mac and not discovery_timeout: + callback( + PayloadSample(mac_address=mac_address, sample=MagicMock()), + (resolved_host, 1234), + ) + + listener.register.side_effect = _on_register + + with ExitStack() as stack: + mock_verify = stack.enter_context( + patch( + "homeassistant.components.bitvis.config_flow.async_verify_udp_port_bindable", + new_callable=AsyncMock, + side_effect=port_bind_side_effect, + ) + ) + stack.enter_context( + patch( + "homeassistant.components.bitvis.config_flow.async_resolve_host", + new_callable=AsyncMock, + return_value={resolved_host}, + ) + ) + stack.enter_context( + patch( + "homeassistant.components.bitvis.coordinator.SharedListener", + return_value=listener, + ) + ) + if discovery_timeout: + stack.enter_context( + patch( + "homeassistant.components.bitvis.config_flow.DISCOVERY_TIMEOUT", + 0, + ) + ) + yield mock_verify + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.100", CONF_PORT: DEFAULT_PORT}, + unique_id=TEST_DEVICE_MAC, + title=DEFAULT_NAME, + ) + + +@pytest.fixture +def mock_zeroconf_config_entry() -> MockConfigEntry: + """Return a mocked config entry for zeroconf discovery host.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.200", CONF_PORT: DEFAULT_PORT}, + unique_id=TEST_DEVICE_MAC, + title=DEFAULT_NAME, + ) + + +@pytest.fixture +def mock_ipv6_config_entry() -> MockConfigEntry: + """Return a mocked config entry with an IPv6 host.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "2001:db8::10", CONF_PORT: DEFAULT_PORT}, + unique_id=SECOND_DEVICE_MAC, + title=DEFAULT_NAME, + ) + + +@pytest.fixture +def mock_second_config_entry() -> MockConfigEntry: + """Return a second mocked config entry on the same UDP port.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.101", CONF_PORT: DEFAULT_PORT}, + unique_id=SECOND_DEVICE_MAC, + title=DEFAULT_NAME, + ) + + +@pytest.fixture +def mock_shared_listener() -> FakeListener: + """Return a fake bitvis_protobuf SharedListener.""" + return FakeListener() + + +@pytest.fixture +def patch_shared_listener( + mock_shared_listener: FakeListener, +) -> Generator[FakeListener]: + """Patch SharedListener to return a mocked instance.""" + with patch( + "homeassistant.components.bitvis.coordinator.SharedListener", + return_value=mock_shared_listener, + ): + yield mock_shared_listener + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.bitvis.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + patch_shared_listener: FakeListener, +) -> MockConfigEntry: + """Set up the integration with a mocked UDP listener.""" + await setup_integration(hass, mock_config_entry) + return mock_config_entry diff --git a/tests/components/bitvis/snapshots/test_sensor.ambr b/tests/components/bitvis/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..27e9306840f08d --- /dev/null +++ b/tests/components/bitvis/snapshots/test_sensor.ambr @@ -0,0 +1,1721 @@ +# serializer version: 1 +# name: test_all_entities[sensor.bitvis_power_hub_active_energy_export-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bitvis_power_hub_active_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Active energy export', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active energy export', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_active_export', + 'unique_id': 'aa:bb:cc:dd:ee:ff_energy_active_delivered_by_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_active_energy_export-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Bitvis Power Hub Active energy export', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_active_energy_export', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '789.119995117188', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_active_energy_import-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bitvis_power_hub_active_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Active energy import', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active energy import', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_active_import', + 'unique_id': 'aa:bb:cc:dd:ee:ff_energy_active_delivered_to_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_active_energy_import-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Bitvis Power Hub Active energy import', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_active_energy_import', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1234.56005859375', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_active_power_export-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bitvis_power_hub_active_power_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Active power export', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power export', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_active_export', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_delivered_by_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_active_power_export-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Bitvis Power Hub Active power export', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_active_power_export', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_active_power_export_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bitvis_power_hub_active_power_export_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Active power export L1', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power export L1', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_active_phase_export', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_l1_delivered_by_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_active_power_export_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Bitvis Power Hub Active power export L1', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_active_power_export_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_active_power_export_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bitvis_power_hub_active_power_export_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Active power export L2', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power export L2', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_active_phase_export', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_l2_delivered_by_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_active_power_export_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Bitvis Power Hub Active power export L2', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_active_power_export_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_active_power_export_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bitvis_power_hub_active_power_export_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Active power export L3', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power export L3', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_active_phase_export', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_l3_delivered_by_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_active_power_export_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Bitvis Power Hub Active power export L3', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_active_power_export_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_active_power_import-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bitvis_power_hub_active_power_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Active power import', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power import', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_active_import', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_delivered_to_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_active_power_import-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Bitvis Power Hub Active power import', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_active_power_import', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.41499996185303', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_active_power_import_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bitvis_power_hub_active_power_import_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Active power import L1', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power import L1', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_active_phase_import', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_l1_delivered_to_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_active_power_import_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Bitvis Power Hub Active power import L1', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_active_power_import_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.800000011920929', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_active_power_import_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bitvis_power_hub_active_power_import_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Active power import L2', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power import L2', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_active_phase_import', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_l2_delivered_to_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_active_power_import_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Bitvis Power Hub Active power import L2', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_active_power_import_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.699999988079071', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_active_power_import_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bitvis_power_hub_active_power_import_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Active power import L3', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power import L3', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_active_phase_import', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_l3_delivered_to_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_active_power_import_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Bitvis Power Hub Active power import L3', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_active_power_import_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.915000021457672', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_current_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bitvis_power_hub_current_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current L1', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current L1', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_current', + 'unique_id': 'aa:bb:cc:dd:ee:ff_phase_current_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_current_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'Bitvis Power Hub Current L1', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_current_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.5', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_current_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bitvis_power_hub_current_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current L2', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current L2', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_current', + 'unique_id': 'aa:bb:cc:dd:ee:ff_phase_current_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_current_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'Bitvis Power Hub Current L2', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_current_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.30000019073486', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_current_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bitvis_power_hub_current_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current L3', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current L3', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_current', + 'unique_id': 'aa:bb:cc:dd:ee:ff_phase_current_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_current_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'Bitvis Power Hub Current L3', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_current_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.1000003814697', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_han_buffer_overflows-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.bitvis_power_hub_han_buffer_overflows', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'HAN buffer overflows', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HAN buffer overflows', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'han_msg_buffer_overflow', + 'unique_id': 'aa:bb:cc:dd:ee:ff_han_msg_buffer_overflow', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_han_buffer_overflows-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bitvis Power Hub HAN buffer overflows', + : , + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_han_buffer_overflows', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_han_messages_successfully_parsed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.bitvis_power_hub_han_messages_successfully_parsed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'HAN messages successfully parsed', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HAN messages successfully parsed', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'han_msg_successfully_parsed', + 'unique_id': 'aa:bb:cc:dd:ee:ff_han_msg_successfully_parsed', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_han_messages_successfully_parsed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bitvis Power Hub HAN messages successfully parsed', + : , + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_han_messages_successfully_parsed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1000', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_reactive_energy_export-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bitvis_power_hub_reactive_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reactive energy export', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive energy export', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_reactive_export', + 'unique_id': 'aa:bb:cc:dd:ee:ff_energy_reactive_delivered_by_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_reactive_energy_export-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'reactive_energy', + : 'Bitvis Power Hub Reactive energy export', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_reactive_energy_export', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23.4500007629395', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_reactive_energy_import-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bitvis_power_hub_reactive_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reactive energy import', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive energy import', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_reactive_import', + 'unique_id': 'aa:bb:cc:dd:ee:ff_energy_reactive_delivered_to_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_reactive_energy_import-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'reactive_energy', + : 'Bitvis Power Hub Reactive energy import', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_reactive_energy_import', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45.6699981689453', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_reactive_power_export-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bitvis_power_hub_reactive_power_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reactive power export', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power export', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive_export', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_delivered_by_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_reactive_power_export-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'reactive_power', + : 'Bitvis Power Hub Reactive power export', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_reactive_power_export', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_reactive_power_export_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bitvis_power_hub_reactive_power_export_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reactive power export L1', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power export L1', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive_phase_export', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_l1_delivered_by_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_reactive_power_export_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'reactive_power', + : 'Bitvis Power Hub Reactive power export L1', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_reactive_power_export_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_reactive_power_export_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bitvis_power_hub_reactive_power_export_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reactive power export L2', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power export L2', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive_phase_export', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_l2_delivered_by_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_reactive_power_export_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'reactive_power', + : 'Bitvis Power Hub Reactive power export L2', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_reactive_power_export_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_reactive_power_export_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bitvis_power_hub_reactive_power_export_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reactive power export L3', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power export L3', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive_phase_export', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_l3_delivered_by_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_reactive_power_export_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'reactive_power', + : 'Bitvis Power Hub Reactive power export L3', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_reactive_power_export_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_reactive_power_import-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bitvis_power_hub_reactive_power_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reactive power import', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power import', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive_import', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_delivered_to_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_reactive_power_import-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'reactive_power', + : 'Bitvis Power Hub Reactive power import', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_reactive_power_import', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.5', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_reactive_power_import_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bitvis_power_hub_reactive_power_import_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reactive power import L1', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power import L1', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive_phase_import', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_l1_delivered_to_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_reactive_power_import_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'reactive_power', + : 'Bitvis Power Hub Reactive power import L1', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_reactive_power_import_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.200000002980232', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_reactive_power_import_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bitvis_power_hub_reactive_power_import_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reactive power import L2', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power import L2', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive_phase_import', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_l2_delivered_to_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_reactive_power_import_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'reactive_power', + : 'Bitvis Power Hub Reactive power import L2', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_reactive_power_import_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.150000005960464', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_reactive_power_import_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bitvis_power_hub_reactive_power_import_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reactive power import L3', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power import L3', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive_phase_import', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_l3_delivered_to_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_reactive_power_import_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'reactive_power', + : 'Bitvis Power Hub Reactive power import L3', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_reactive_power_import_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.150000005960464', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_uptime-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': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.bitvis_power_hub_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Uptime', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'uptime', + : 'Bitvis Power Hub Uptime', + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-12-31T12:00:00+00:00', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_voltage_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bitvis_power_hub_voltage_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voltage L1', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage L1', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_voltage', + 'unique_id': 'aa:bb:cc:dd:ee:ff_phase_voltage_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_voltage_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Bitvis Power Hub Voltage L1', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_voltage_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.0', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_voltage_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bitvis_power_hub_voltage_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voltage L2', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage L2', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_voltage', + 'unique_id': 'aa:bb:cc:dd:ee:ff_phase_voltage_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_voltage_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Bitvis Power Hub Voltage L2', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_voltage_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '229.5', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_voltage_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bitvis_power_hub_voltage_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voltage L3', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage L3', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_voltage', + 'unique_id': 'aa:bb:cc:dd:ee:ff_phase_voltage_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_voltage_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Bitvis Power Hub Voltage L3', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_voltage_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '231.199996948242', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_wi_fi_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.bitvis_power_hub_wi_fi_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wi-Fi signal strength', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wi-Fi signal strength', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_rssi', + 'unique_id': 'aa:bb:cc:dd:ee:ff_wifi_rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_all_entities[sensor.bitvis_power_hub_wi_fi_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'signal_strength', + : 'Bitvis Power Hub Wi-Fi signal strength', + : , + : 'dBm', + }), + 'context': , + 'entity_id': 'sensor.bitvis_power_hub_wi_fi_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-65', + }) +# --- diff --git a/tests/components/bitvis/test_config_flow.py b/tests/components/bitvis/test_config_flow.py new file mode 100644 index 00000000000000..8fc292c0121593 --- /dev/null +++ b/tests/components/bitvis/test_config_flow.py @@ -0,0 +1,613 @@ +"""Tests for the Bitvis Power Hub config flow.""" + +import asyncio +from ipaddress import ip_address +from unittest.mock import AsyncMock, MagicMock, patch + +from bitvis_protobuf.parse import PayloadSample +from bitvis_protobuf.powerhub_pb2 import Payload +import pytest + +from homeassistant.components.bitvis.const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN +from homeassistant.components.bitvis.coordinator import async_get_listener_registry +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from .conftest import ( + SECOND_DEVICE_MAC, + TEST_DEVICE_MAC, + FakeListener, + patch_config_flow_connectivity, +) + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + +ZEROCONF_HOST = "192.168.1.200" +USER_HOST = "192.168.1.100" + + +def _zeroconf_discovery( + host: str = ZEROCONF_HOST, + name: str = "Bitvis Power Hub._powerhub._udp.local.", + port: int | None = DEFAULT_PORT, +) -> ZeroconfServiceInfo: + return ZeroconfServiceInfo( + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], + hostname="powerhub.local.", + name=name, + port=port, + properties={}, + type="_powerhub._udp.local.", + ) + + +ZEROCONF_DISCOVERY = _zeroconf_discovery() +UNRELATED_HOST = "10.9.9.9" + + +def _invalid_mac_datagram() -> bytes: + payload = Payload() + payload.sample.SetInParent() + return payload.SerializeToString() + + +@pytest.mark.parametrize( + ("input_host", "resolved_ip", "expected_host"), + [ + pytest.param(USER_HOST, USER_HOST, USER_HOST, id="ipv4"), + pytest.param("2001:db8::10", "2001:db8::10", "2001:db8::10", id="ipv6"), + pytest.param( + "my-powerhub.local", "10.0.0.5", "my-powerhub.local", id="hostname" + ), + pytest.param( + "[2001:db8::10]", "2001:db8::10", "2001:db8::10", id="bracketed-ipv6" + ), + ], +) +async def test_user_form_create_entry( + hass: HomeAssistant, + input_host: str, + resolved_ip: str, + expected_host: str, +) -> None: + """Test creating an entry via user flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with patch_config_flow_connectivity(resolved_ip): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: input_host, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["data"] == { + CONF_HOST: expected_host, + CONF_PORT: DEFAULT_PORT, + } + assert result["result"].unique_id == TEST_DEVICE_MAC + + +@pytest.mark.parametrize( + ("connectivity_kwargs", "error_key"), + [ + pytest.param( + {"port_bind_side_effect": OSError("UDP port is unavailable")}, + "cannot_connect", + id="cannot-connect", + ), + pytest.param({"invalid_mac": True}, "invalid_mac", id="invalid-mac"), + pytest.param( + {"deliver_mac": False, "discovery_timeout": True}, + "timeout_connect", + id="timeout", + ), + ], +) +async def test_user_form_error_and_recovery( + hass: HomeAssistant, + connectivity_kwargs: dict[str, object], + error_key: str, +) -> None: + """Test user form error then successful recovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch_config_flow_connectivity(USER_HOST, **connectivity_kwargs): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: USER_HOST, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_key} + + with patch_config_flow_connectivity(USER_HOST): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: USER_HOST, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["data"] == { + CONF_HOST: USER_HOST, + CONF_PORT: DEFAULT_PORT, + } + assert result["result"].unique_id == TEST_DEVICE_MAC + + +@pytest.mark.parametrize( + "input_host", + [ + pytest.param(USER_HOST, id="same-host"), + pytest.param("192.168.1.101", id="different-host"), + ], +) +async def test_user_form_duplicate_mac( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, input_host: str +) -> None: + """Test duplicate detection is based on MAC address, not host.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch_config_flow_connectivity(input_host): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: input_host, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_user_form_reused_ip_new_device( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test a new device can be added at an IP already stored on another entry.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch_config_flow_connectivity(USER_HOST, mac_address=SECOND_DEVICE_MAC): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: USER_HOST, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: USER_HOST, + CONF_PORT: DEFAULT_PORT, + } + assert result["result"].unique_id == SECOND_DEVICE_MAC + + +@pytest.mark.parametrize( + ("connectivity_kwargs", "reason"), + [ + pytest.param( + {"port_bind_side_effect": OSError("UDP port is unavailable")}, + "cannot_connect", + id="cannot-connect", + ), + pytest.param({"invalid_mac": True}, "invalid_mac", id="invalid-mac"), + pytest.param( + {"deliver_mac": False, "discovery_timeout": True}, + "timeout_connect", + id="timeout", + ), + ], +) +async def test_zeroconf_abort( + hass: HomeAssistant, + connectivity_kwargs: dict[str, object], + reason: str, +) -> None: + """Test zeroconf discovery abort reasons.""" + with patch_config_flow_connectivity(ZEROCONF_HOST, **connectivity_kwargs): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + +async def test_zeroconf_duplicate( + hass: HomeAssistant, mock_zeroconf_config_entry: MockConfigEntry +) -> None: + """Test that a duplicate zeroconf discovery is aborted by MAC address.""" + mock_zeroconf_config_entry.add_to_hass(hass) + + with patch_config_flow_connectivity(ZEROCONF_HOST): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_reused_ip_new_device( + hass: HomeAssistant, mock_zeroconf_config_entry: MockConfigEntry +) -> None: + """Test zeroconf can add a new device at an IP already stored on another entry.""" + mock_zeroconf_config_entry.add_to_hass(hass) + + with patch_config_flow_connectivity(ZEROCONF_HOST, mac_address=SECOND_DEVICE_MAC): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: ZEROCONF_HOST, + CONF_PORT: DEFAULT_PORT, + } + assert result["result"].unique_id == SECOND_DEVICE_MAC + + +@pytest.mark.parametrize( + ("name", "port", "expected_title"), + [ + pytest.param( + "Bitvis Power Hub._powerhub._udp.local.", + DEFAULT_PORT, + "Bitvis Power Hub", + id="happy-path", + ), + pytest.param( + "Bitvis Power Hub._powerhub._udp.local.", + None, + "Bitvis Power Hub", + id="none-port", + ), + pytest.param( + "My Custom Hub._powerhub._udp.local.", + DEFAULT_PORT, + "My Custom Hub", + id="friendly-name", + ), + pytest.param("", DEFAULT_PORT, DEFAULT_NAME, id="empty-name"), + pytest.param( + "._powerhub._udp.local.", DEFAULT_PORT, DEFAULT_NAME, id="dot-prefixed" + ), + ], +) +async def test_zeroconf_create_entry( + hass: HomeAssistant, + name: str, + port: int | None, + expected_title: str, +) -> None: + """Test zeroconf confirm creates an entry with title, data, and unique_id.""" + discovery = _zeroconf_discovery(name=name, port=port) + + with patch_config_flow_connectivity(ZEROCONF_HOST): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == expected_title + assert result["data"] == { + CONF_HOST: ZEROCONF_HOST, + CONF_PORT: DEFAULT_PORT, + } + assert result["result"].unique_id == TEST_DEVICE_MAC + + +async def test_zeroconf_updates_host_on_new_ip( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test rediscovery on a new IP updates the stored host and aborts.""" + mock_config_entry.add_to_hass(hass) + assert mock_config_entry.data[CONF_HOST] != ZEROCONF_HOST + + with patch_config_flow_connectivity(ZEROCONF_HOST): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_HOST] == ZEROCONF_HOST + + +async def test_aborted_flow_removes_listener( + hass: HomeAssistant, + mock_shared_listener: FakeListener, +) -> None: + """Test listener is stopped after an aborted config flow.""" + with patch_config_flow_connectivity( + USER_HOST, + deliver_mac=False, + discovery_timeout=True, + shared_listener=mock_shared_listener, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: USER_HOST, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "timeout_connect"} + mock_shared_listener.stop.assert_awaited_once() + assert not async_get_listener_registry(hass).has_listener(DEFAULT_PORT) + + +async def test_invalid_mac_from_other_host_is_ignored( + hass: HomeAssistant, mock_shared_listener: FakeListener +) -> None: + """Test an invalid-MAC datagram from another host does not fail the flow.""" + with patch_config_flow_connectivity( + USER_HOST, deliver_mac=False, shared_listener=mock_shared_listener + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + configure_task = asyncio.create_task( + hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: USER_HOST, + }, + ) + ) + await hass.async_block_till_done() + + mock_shared_listener.dispatch(_invalid_mac_datagram(), (UNRELATED_HOST, 1234)) + mock_shared_listener.deliver( + PayloadSample(mac_address=TEST_DEVICE_MAC, sample=MagicMock()), + (USER_HOST, 1234), + ) + result = await configure_task + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == TEST_DEVICE_MAC + + +async def test_invalid_mac_does_not_fail_other_flow( + hass: HomeAssistant, mock_shared_listener: FakeListener +) -> None: + """Test an invalid-MAC datagram only fails the flow waiting for that host.""" + + async def resolve_host(host: str) -> set[str]: + return {host} + + with ( + patch( + "homeassistant.components.bitvis.config_flow.async_verify_udp_port_bindable", + new_callable=AsyncMock, + ), + patch( + "homeassistant.components.bitvis.config_flow.async_resolve_host", + side_effect=resolve_host, + ), + patch( + "homeassistant.components.bitvis.coordinator.SharedListener", + return_value=mock_shared_listener, + ), + ): + first_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + first_task = asyncio.create_task( + hass.config_entries.flow.async_configure( + first_result["flow_id"], + { + CONF_HOST: USER_HOST, + }, + ) + ) + await hass.async_block_till_done() + + second_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + second_task = asyncio.create_task( + hass.config_entries.flow.async_configure( + second_result["flow_id"], + { + CONF_HOST: ZEROCONF_HOST, + }, + ) + ) + await hass.async_block_till_done() + + mock_shared_listener.dispatch(_invalid_mac_datagram(), (ZEROCONF_HOST, 1234)) + mock_shared_listener.deliver( + PayloadSample(mac_address=TEST_DEVICE_MAC, sample=MagicMock()), + (USER_HOST, 1234), + ) + first_result = await first_task + second_result = await second_task + + assert first_result["type"] is FlowResultType.CREATE_ENTRY + assert first_result["result"].unique_id == TEST_DEVICE_MAC + assert second_result["type"] is FlowResultType.FORM + assert second_result["errors"] == {"base": "invalid_mac"} + + +async def test_concurrent_flow_same_host_aborts(hass: HomeAssistant) -> None: + """Test concurrent flows for the same host abort with already_in_progress.""" + with patch_config_flow_connectivity(USER_HOST, deliver_mac=False): + first_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + first_task = asyncio.create_task( + hass.config_entries.flow.async_configure( + first_result["flow_id"], + { + CONF_HOST: USER_HOST, + }, + ) + ) + await hass.async_block_till_done() + + second_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + second_result = await hass.config_entries.flow.async_configure( + second_result["flow_id"], + { + CONF_HOST: USER_HOST, + }, + ) + + first_task.cancel() + with pytest.raises(asyncio.CancelledError): + await first_task + + assert second_result["type"] is FlowResultType.ABORT + assert second_result["reason"] == "already_in_progress" + + +async def test_discovery_register_runtime_error_aborts(hass: HomeAssistant) -> None: + """Test discovery aborts when filter registration raises RuntimeError.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch_config_flow_connectivity( + USER_HOST, + deliver_mac=False, + register_side_effect=RuntimeError("Filter already registered"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: USER_HOST, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_in_progress" + + +async def test_zeroconf_concurrent_flow_same_host_aborts(hass: HomeAssistant) -> None: + """Test concurrent zeroconf flows for the same host abort.""" + with patch_config_flow_connectivity(ZEROCONF_HOST, deliver_mac=False): + first_task = asyncio.create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + ) + await hass.async_block_till_done() + + second_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + + first_task.cancel() + with pytest.raises(asyncio.CancelledError): + await first_task + + assert second_result["type"] is FlowResultType.ABORT + assert second_result["reason"] == "already_in_progress" + + +@pytest.mark.parametrize( + ("listener_already_running", "expected_awaits"), + [ + pytest.param(True, 0, id="listener-exists"), + pytest.param(False, 1, id="no-listener"), + ], +) +async def test_user_form_port_bind_check( + hass: HomeAssistant, + mock_shared_listener: FakeListener, + listener_already_running: bool, + expected_awaits: int, +) -> None: + """Test user flow skips the port bind check only when a listener exists.""" + if listener_already_running: + with patch( + "homeassistant.components.bitvis.coordinator.SharedListener", + return_value=mock_shared_listener, + ): + await async_get_listener_registry(hass).async_get_or_create(DEFAULT_PORT) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + kwargs: dict[str, object] = { + "mac_address": SECOND_DEVICE_MAC + if listener_already_running + else TEST_DEVICE_MAC + } + if listener_already_running: + kwargs["shared_listener"] = mock_shared_listener + + with patch_config_flow_connectivity("192.168.1.101", **kwargs) as mock_verify: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "192.168.1.101", + }, + ) + + assert mock_verify.await_count == expected_awaits + assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/bitvis/test_coordinator.py b/tests/components/bitvis/test_coordinator.py new file mode 100644 index 00000000000000..0a3b79d2d201cb --- /dev/null +++ b/tests/components/bitvis/test_coordinator.py @@ -0,0 +1,45 @@ +"""Tests for the Bitvis Power Hub coordinator.""" + +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .conftest import FakeListener + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("patch_shared_listener") + + +async def test_setup_oserror_results_in_setup_retry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_shared_listener: FakeListener, +) -> None: + """Test that OSError from SharedListener.start results in SETUP_RETRY.""" + mock_shared_listener.start = AsyncMock(side_effect=OSError("port in use")) + 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.SETUP_RETRY + + +async def test_setup_runtime_error_results_in_setup_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_shared_listener: FakeListener, +) -> None: + """Test that RuntimeError from SharedListener.register results in SETUP_ERROR.""" + mock_shared_listener.register.side_effect = RuntimeError("duplicate filter") + 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.SETUP_ERROR + mock_shared_listener.unregister.assert_not_called() diff --git a/tests/components/bitvis/test_init.py b/tests/components/bitvis/test_init.py new file mode 100644 index 00000000000000..41f3b39999a7be --- /dev/null +++ b/tests/components/bitvis/test_init.py @@ -0,0 +1,99 @@ +"""Tests for the Bitvis Power Hub integration.""" + +from bitvis_protobuf import powerhub_pb2 +from bitvis_protobuf.parse import PayloadSample +import pytest + +from homeassistant.components.bitvis.const import DATA_LISTENER_REGISTRY, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import find_listener_callback, setup_integration +from .conftest import TEST_DEVICE_MAC, FakeListener + +from tests.common import MockConfigEntry + + +async def test_setup_entry( + init_integration: MockConfigEntry, +) -> None: + """Test successful integration setup.""" + assert init_integration.state is ConfigEntryState.LOADED + + +async def test_unload_entry( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test that unloading stops the coordinator and unloads platforms.""" + assert DATA_LISTENER_REGISTRY in hass.data + assert await hass.config_entries.async_unload(init_integration.entry_id) + assert init_integration.state is ConfigEntryState.NOT_LOADED + assert DATA_LISTENER_REGISTRY not in hass.data + + +async def test_two_entries_share_listener( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_second_config_entry: MockConfigEntry, + patch_shared_listener: FakeListener, +) -> None: + """Test that two entries on the same port share one library listener.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_second_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_second_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_second_config_entry.state is ConfigEntryState.LOADED + patch_shared_listener.start.assert_awaited_once() + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + patch_shared_listener.stop.assert_not_called() + assert DATA_LISTENER_REGISTRY in hass.data + + assert await hass.config_entries.async_unload(mock_second_config_entry.entry_id) + patch_shared_listener.stop.assert_awaited_once() + assert DATA_LISTENER_REGISTRY not in hass.data + + +async def test_unload_after_dynamic_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + patch_shared_listener: FakeListener, +) -> None: + """Test unload succeeds after HAN sensors have been created dynamically.""" + await setup_integration(hass, mock_config_entry) + + payload = powerhub_pb2.Payload() + payload.sample.power_active_delivered_to_client_kw = 2.0 + find_listener_callback(patch_shared_listener, TEST_DEVICE_MAC)( + PayloadSample(mac_address=TEST_DEVICE_MAC, sample=payload.sample), + ("192.168.1.100", 1234), + ) + await hass.async_block_till_done() + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entities_unavailable_before_data( + hass: HomeAssistant, + init_integration: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test diagnostic entities are unavailable after setup before any packet.""" + wifi_entity_id = entity_registry.async_get_entity_id( + "sensor", + DOMAIN, + f"{TEST_DEVICE_MAC}_wifi_rssi", + ) + assert wifi_entity_id is not None + state = hass.states.get(wifi_entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/bitvis/test_sensor.py b/tests/components/bitvis/test_sensor.py new file mode 100644 index 00000000000000..2aab629157f3c0 --- /dev/null +++ b/tests/components/bitvis/test_sensor.py @@ -0,0 +1,293 @@ +"""Tests for the Bitvis Power Hub sensor platform.""" + +from unittest.mock import patch + +from bitvis_protobuf import powerhub_pb2 +from bitvis_protobuf.parse import PayloadDiagnostic, PayloadSample +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.bitvis.const import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC + +from . import find_listener_callback, setup_integration +from .conftest import TEST_DEVICE_MAC, FakeListener + +from tests.common import MockConfigEntry, snapshot_platform + +DIAGNOSTIC_UNIQUE_IDS = { + f"{TEST_DEVICE_MAC}_uptime", + f"{TEST_DEVICE_MAC}_wifi_rssi", + f"{TEST_DEVICE_MAC}_han_msg_successfully_parsed", + f"{TEST_DEVICE_MAC}_han_msg_buffer_overflow", +} + + +@pytest.fixture(autouse=True) +def enable_all_entities(entity_registry_enabled_by_default: None) -> None: + """Make sure all entities are enabled.""" + + +@pytest.fixture +def sample_payload() -> PayloadSample: + """Return a sample payload with test data.""" + payload = powerhub_pb2.Payload() + payload.sample.phase_voltage_l1_v = 230.0 + payload.sample.phase_voltage_l2_v = 229.5 + payload.sample.phase_voltage_l3_v = 231.2 + payload.sample.phase_current_l1_a = 10.5 + payload.sample.phase_current_l2_a = 8.3 + payload.sample.phase_current_l3_a = 12.1 + payload.sample.power_active_delivered_to_client_kw = 2.415 + payload.sample.power_active_delivered_by_client_kw = 0.0 + payload.sample.power_reactive_delivered_to_client_kvar = 0.5 + payload.sample.power_reactive_delivered_by_client_kvar = 0.0 + payload.sample.power_active_l1_delivered_to_client_kw = 0.8 + payload.sample.power_active_l2_delivered_to_client_kw = 0.7 + payload.sample.power_active_l3_delivered_to_client_kw = 0.915 + payload.sample.power_active_l1_delivered_by_client_kw = 0.0 + payload.sample.power_active_l2_delivered_by_client_kw = 0.0 + payload.sample.power_active_l3_delivered_by_client_kw = 0.0 + payload.sample.power_reactive_l1_delivered_to_client_kvar = 0.2 + payload.sample.power_reactive_l2_delivered_to_client_kvar = 0.15 + payload.sample.power_reactive_l3_delivered_to_client_kvar = 0.15 + payload.sample.power_reactive_l1_delivered_by_client_kvar = 0.0 + payload.sample.power_reactive_l2_delivered_by_client_kvar = 0.0 + payload.sample.power_reactive_l3_delivered_by_client_kvar = 0.0 + payload.sample.energy_active_delivered_to_client_kwh = 1234.56 + payload.sample.energy_active_delivered_by_client_kwh = 789.12 + payload.sample.energy_reactive_delivered_to_client_kvarh = 45.67 + payload.sample.energy_reactive_delivered_by_client_kvarh = 23.45 + return PayloadSample(mac_address=TEST_DEVICE_MAC, sample=payload.sample) + + +@pytest.fixture +def diagnostic_payload() -> PayloadDiagnostic: + """Return a diagnostic payload with test data.""" + payload = powerhub_pb2.Payload() + payload.diagnostic.uptime_s = 86400 + payload.diagnostic.wifi_rssi_dbm = -65 + payload.diagnostic.device_info.model_name = "PowerHub Gen2" + payload.diagnostic.device_info.sw_version = "2.0.0" + payload.diagnostic.device_info.mac_address = b"\xaa\xbb\xcc\xdd\xee\xff" + payload.diagnostic.han_msg_successfully_parsed = 1000 + payload.diagnostic.han_msg_buffer_overflow = 5 + return PayloadDiagnostic(mac_address=TEST_DEVICE_MAC, diagnostic=payload.diagnostic) + + +@pytest.mark.freeze_time("2026-01-01 12:00:00") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + sample_payload: PayloadSample, + diagnostic_payload: PayloadDiagnostic, + patch_shared_listener: FakeListener, +) -> None: + """Test all entities with snapshot.""" + with patch("homeassistant.components.bitvis._PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + callback = find_listener_callback(patch_shared_listener, TEST_DEVICE_MAC) + callback(sample_payload, ("192.168.1.100", 1234)) + callback(diagnostic_payload, ("192.168.1.100", 1234)) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_entities_added_when_fields_become_available( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + patch_shared_listener: FakeListener, +) -> None: + """Test that HAN sensors are created when their fields first appear.""" + base_unique_id = mock_config_entry.unique_id + + payload = powerhub_pb2.Payload() + payload.sample.power_active_delivered_to_client_kw = 2.0 + find_listener_callback(patch_shared_listener, TEST_DEVICE_MAC)( + PayloadSample(mac_address=TEST_DEVICE_MAC, sample=payload.sample), + ("192.168.1.100", 1234), + ) + await hass.async_block_till_done() + + unique_ids = { + entry.unique_id + for entry in er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + } + assert unique_ids == DIAGNOSTIC_UNIQUE_IDS | { + f"{base_unique_id}_power_active_delivered_to_client" + } + + payload.sample.phase_voltage_l1_v = 230.0 + find_listener_callback(patch_shared_listener, TEST_DEVICE_MAC)( + PayloadSample(mac_address=TEST_DEVICE_MAC, sample=payload.sample), + ("192.168.1.100", 1234), + ) + await hass.async_block_till_done() + + unique_ids = { + entry.unique_id + for entry in er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + } + assert unique_ids == DIAGNOSTIC_UNIQUE_IDS | { + f"{base_unique_id}_power_active_delivered_to_client", + f"{base_unique_id}_phase_voltage_l1", + } + + +@pytest.mark.usefixtures("init_integration") +async def test_sensors_become_available_with_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + patch_shared_listener: FakeListener, +) -> None: + """Test that sensors become available when data arrives.""" + payload = powerhub_pb2.Payload() + payload.sample.power_active_delivered_to_client_kw = 2.0 + find_listener_callback(patch_shared_listener, TEST_DEVICE_MAC)( + PayloadSample(mac_address=TEST_DEVICE_MAC, sample=payload.sample), + ("192.168.1.100", 1234), + ) + await hass.async_block_till_done() + + entity_id = entity_registry.async_get_entity_id( + "sensor", + DOMAIN, + f"{mock_config_entry.unique_id}_power_active_delivered_to_client", + ) + assert entity_id is not None + state = hass.states.get(entity_id) + assert state is not None + assert state.state != "unavailable" + assert float(state.state) == pytest.approx(2.0) + + +@pytest.mark.usefixtures("init_integration") +async def test_diagnostic_sensors_update_with_data( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + patch_shared_listener: FakeListener, +) -> None: + """Test that diagnostic sensors update when a diagnostic payload arrives.""" + payload = powerhub_pb2.Payload() + payload.diagnostic.uptime_s = 999 + payload.diagnostic.wifi_rssi_dbm = -70 + find_listener_callback(patch_shared_listener, TEST_DEVICE_MAC)( + PayloadDiagnostic(mac_address=TEST_DEVICE_MAC, diagnostic=payload.diagnostic), + ("192.168.1.100", 1234), + ) + await hass.async_block_till_done() + + wifi_entity_id = entity_registry.async_get_entity_id( + "sensor", + DOMAIN, + f"{TEST_DEVICE_MAC}_wifi_rssi", + ) + assert wifi_entity_id is not None + wifi_state = hass.states.get(wifi_entity_id) + assert wifi_state is not None + assert wifi_state.state != "unavailable" + assert float(wifi_state.state) == pytest.approx(-70) + + uptime_entity_id = entity_registry.async_get_entity_id( + "sensor", + DOMAIN, + f"{TEST_DEVICE_MAC}_uptime", + ) + assert uptime_entity_id is not None + uptime_state = hass.states.get(uptime_entity_id) + assert uptime_state is not None + assert uptime_state.state != "unavailable" + + +@pytest.mark.usefixtures("init_integration") +async def test_device_info_updated_from_diagnostic( + hass: HomeAssistant, + init_integration: MockConfigEntry, + device_registry: dr.DeviceRegistry, + patch_shared_listener: FakeListener, +) -> None: + """Test that device info is updated from a diagnostic payload.""" + device = device_registry.async_get_device_by_identifier( + (DOMAIN, TEST_DEVICE_MAC), init_integration.entry_id + ) + assert device is not None + assert device.model is None + assert device.sw_version is None + assert (CONNECTION_NETWORK_MAC, TEST_DEVICE_MAC) in device.connections + + payload = powerhub_pb2.Payload() + payload.diagnostic.uptime_s = 10 + payload.diagnostic.device_info.model_name = "PowerHub Gen2" + payload.diagnostic.device_info.sw_version = "1.2.3" + payload.diagnostic.device_info.mac_address = b"\xaa\xbb\xcc\xdd\xee\xff" + find_listener_callback(patch_shared_listener, TEST_DEVICE_MAC)( + PayloadDiagnostic(mac_address=TEST_DEVICE_MAC, diagnostic=payload.diagnostic), + ("192.168.1.100", 1234), + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device_by_identifier( + (DOMAIN, TEST_DEVICE_MAC), init_integration.entry_id + ) + assert device is not None + assert device.model == "PowerHub Gen2" + assert device.sw_version == "1.2.3" + + +@pytest.mark.usefixtures("init_integration") +async def test_device_info_kept_when_absent_in_later_payload( + hass: HomeAssistant, + init_integration: MockConfigEntry, + device_registry: dr.DeviceRegistry, + sample_payload: PayloadSample, + patch_shared_listener: FakeListener, +) -> None: + """Test that known model/sw_version are kept when later payloads omit them.""" + payload = powerhub_pb2.Payload() + payload.diagnostic.uptime_s = 10 + payload.diagnostic.device_info.model_name = "PowerHub" + payload.diagnostic.device_info.sw_version = "1.0" + payload.diagnostic.device_info.mac_address = b"\xaa\xbb\xcc\xdd\xee\xff" + callback = find_listener_callback(patch_shared_listener, TEST_DEVICE_MAC) + callback( + PayloadDiagnostic(mac_address=TEST_DEVICE_MAC, diagnostic=payload.diagnostic), + ("192.168.1.100", 1234), + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device_by_identifier( + (DOMAIN, TEST_DEVICE_MAC), init_integration.entry_id + ) + assert device is not None + assert device.model == "PowerHub" + assert device.sw_version == "1.0" + + payload2 = powerhub_pb2.Payload() + payload2.diagnostic.uptime_s = 20 + callback( + PayloadDiagnostic(mac_address=TEST_DEVICE_MAC, diagnostic=payload2.diagnostic), + ("192.168.1.100", 1234), + ) + callback(sample_payload, ("192.168.1.100", 1234)) + await hass.async_block_till_done() + + device = device_registry.async_get_device_by_identifier( + (DOMAIN, TEST_DEVICE_MAC), init_integration.entry_id + ) + assert device is not None + assert device.model == "PowerHub" + assert device.sw_version == "1.0" From fae77cc1f58bb9a62875f099f4518bfd36359c3d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 10 Sep 2026 10:56:46 -0500 Subject: [PATCH 02/21] Bump aioesphomeapi to 46.4.0 (#181856) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- tests/components/esphome/snapshots/test_diagnostics.ambr | 1 + tests/components/esphome/test_diagnostics.py | 1 + 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 7f8e70006f55da..fb3b62aa43a26b 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==46.2.0", + "aioesphomeapi==46.4.0", "esphome-dashboard-api==1.4.0", "bleak-esphome==4.0.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 71cb729f865074..304253cb87f727 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -260,7 +260,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==46.2.0 +aioesphomeapi==46.4.0 # homeassistant.components.matrix # homeassistant.components.slack diff --git a/tests/components/esphome/snapshots/test_diagnostics.ambr b/tests/components/esphome/snapshots/test_diagnostics.ambr index 6bd49068573591..5738a78a14c6d1 100644 --- a/tests/components/esphome/snapshots/test_diagnostics.ambr +++ b/tests/components/esphome/snapshots/test_diagnostics.ambr @@ -84,6 +84,7 @@ 'device_info': dict({ 'api_encryption_provisionable': False, 'api_encryption_supported': False, + 'api_outgoing_connection_supported': False, 'area': dict({ 'area_id': 0, 'name': '', diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index 7ecc3dc8872119..4fcd8d4639974d 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -131,6 +131,7 @@ async def test_diagnostics_with_bluetooth( "device_info": { "api_encryption_provisionable": False, "api_encryption_supported": False, + "api_outgoing_connection_supported": False, "area": {"area_id": 0, "name": ""}, "areas": [], "bluetooth_mac_address": "**REDACTED**", From ddcdfbf148ffd1ab43efc39d0476c802677b8b9d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 10 Sep 2026 11:14:42 -0500 Subject: [PATCH 03/21] Add support for ESPHome device-initiated connections (#180961) --- homeassistant/components/esphome/__init__.py | 11 +- homeassistant/components/esphome/manager.py | 60 +++++- .../components/esphome/outgoing_connection.py | 40 ++++ tests/components/esphome/conftest.py | 24 +++ tests/components/esphome/test_init.py | 2 + tests/components/esphome/test_manager.py | 3 + .../esphome/test_outgoing_connection.py | 197 ++++++++++++++++++ 7 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/esphome/outgoing_connection.py create mode 100644 tests/components/esphome/test_outgoing_connection.py diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 2ab5451e6d96b7..7e52b7c7a6f3a8 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -95,7 +95,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> b zeroconf_instance = await zeroconf.async_get_instance(hass) cli = async_create_api_client( - hass, entry, zeroconf_instance, noise_psk=entry.data.get(CONF_NOISE_PSK) + hass, + entry, + zeroconf_instance, + noise_psk=entry.data.get(CONF_NOISE_PSK), + declare_outgoing_target=True, ) domain_data = DomainData.get(hass) @@ -154,7 +158,10 @@ async def _async_clear_dynamic_encryption_key( zeroconf_instance = await zeroconf.async_get_instance(hass) cli = async_create_api_client( - hass, entry, zeroconf_instance, noise_psk=entry.data.get(CONF_NOISE_PSK) + hass, + entry, + zeroconf_instance, + noise_psk=entry.data.get(CONF_NOISE_PSK), ) try: diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 2ea1405941001b..38014b20bf80db 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -103,11 +103,20 @@ # Import config flow so that it's added to the registry from .entry_data import ESPHomeConfigEntry, RuntimeEntryData from .enum_mapper import EsphomeEnumMapper +from .outgoing_connection import async_register_outgoing_target DEVICE_CONFLICT_ISSUE_FORMAT = "device_conflict-{}" UNPACK_UINT32_BE = struct.Struct(">I").unpack_from +@callback +def _mac_unique_id(entry: ESPHomeConfigEntry) -> str | None: + """Dial-in routing is keyed by MAC; pre-2023 name unique ids have none.""" + if (unique_id := entry.unique_id) is not None and ":" in unique_id: + return unique_id + return None + + @callback def async_create_api_client( hass: HomeAssistant, @@ -115,8 +124,13 @@ def async_create_api_client( zeroconf_instance: zeroconf.HaZeroconf, *, noise_psk: str | None, + declare_outgoing_target: bool = False, ) -> APIClient: - """Create an APIClient for a config entry.""" + """Create an APIClient for a config entry. + + Only the entry's long-lived session declares itself a dial-back target; + key management sessions must never be remembered by the device. + """ return APIClient( entry.data[CONF_HOST], entry.data[CONF_PORT], @@ -125,6 +139,10 @@ def async_create_api_client( zeroconf_instance=zeroconf_instance, noise_psk=noise_psk, timezone=hass.config.time_zone, + # The library drops the declaration without a real key; the MAC + # gate is ours, a route needs a MAC unique id + outgoing_connection_target=declare_outgoing_target + and _mac_unique_id(entry) is not None, ) @@ -577,6 +595,36 @@ def _async_subscribe_logs(self, log_level: LogLevel) -> None: self._async_on_log, self._log_level ) + @callback + def _async_register_outgoing_target(self, reconnect_logic: ReconnectLogic) -> None: + """Register this entry's MAC with the shared dial-in listener. + + Runs once at setup; a dynamically provisioned key takes effect on + the reload that follows it: the device drops the keyless session + and the reauth flow reloads the entry with the stored key. + """ + entry = self.entry + # Read the flag off the client so the route matches the hello; the + # flag implies a MAC unique id, which is the routing key + if ( + not self.cli.outgoing_connection_target + or (mac := _mac_unique_id(entry)) is None + ): + _LOGGER.debug("%s: Not routing dial-ins; not a target", entry.title) + return + try: + unregister = async_register_outgoing_target(self.hass, mac, reconnect_logic) + except Exception: + # Dial-in is an optional extra; it must not fail the entry + _LOGGER.exception("%s: Could not set up dial-in routing", entry.title) + return + if unregister is None: + # Shutting down; the device is on its own until the next reload + _LOGGER.debug("%s: Dial-in routing not started", entry.title) + return + # async_on_unload also runs when setup fails or is cancelled + entry.async_on_unload(unregister) + async def _on_connect(self) -> None: """Subscribe to states and list entities on successful API login.""" entry = self.entry @@ -850,7 +898,10 @@ async def _async_provision_key_over_noise(self, new_key: bytes) -> bool: """ unique_id = self.entry.unique_id cli = async_create_api_client( - self.hass, self.entry, self.zeroconf_instance, noise_psk=ZERO_NOISE_PSK + self.hass, + self.entry, + self.zeroconf_instance, + noise_psk=ZERO_NOISE_PSK, ) device_name = self.entry.data.get(CONF_DEVICE_NAME, self.host) try: @@ -1174,6 +1225,11 @@ async def async_start(self) -> None: await reconnect_logic.start() + # After start(), the last call that can raise, so a failed setup + # cannot leak the route; before the BLE wait below so a dial-in can + # satisfy the first connect during a short wake window + self._async_register_outgoing_target(reconnect_logic) + # Wait for a cached BLE proxy to register its scanner before finishing setup. if ( device_info := entry_data.device_info diff --git a/homeassistant/components/esphome/outgoing_connection.py b/homeassistant/components/esphome/outgoing_connection.py new file mode 100644 index 00000000000000..3ac3ccc599dbd4 --- /dev/null +++ b/homeassistant/components/esphome/outgoing_connection.py @@ -0,0 +1,40 @@ +"""Shared listener for ESPHome device-initiated connections.""" + +from aioesphomeapi import OutgoingConnectionServer, ReconnectLogic + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback +from homeassistant.helpers.singleton import singleton +from homeassistant.util.hass_dict import HassKey + +_KEY_OUTGOING_CONNECTION_SERVER: HassKey[OutgoingConnectionServer] = HassKey( + "esphome_outgoing_connection_server" +) + + +@singleton(_KEY_OUTGOING_CONNECTION_SERVER) +@callback +def _async_get_server(hass: HomeAssistant) -> OutgoingConnectionServer: + """Create the shared listener and tie it to Home Assistant's shutdown.""" + server = OutgoingConnectionServer() + + @callback + def _async_hass_stop(event: Event) -> None: + server.close() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_hass_stop) + return server + + +@callback +def async_register_outgoing_target( + hass: HomeAssistant, mac: str, reconnect_logic: ReconnectLogic +) -> CALLBACK_TYPE | None: + """Route dial-ins from this MAC to the reconnect logic. + + The library manages the listener lifecycle. Returns the unregister + callback, or None during shutdown. + """ + if hass.is_stopping: + return None + return _async_get_server(hass).register(mac, reconnect_logic) diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 97bee517b25841..88ecf6d743b9a9 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch from aioesphomeapi import ( + ZERO_NOISE_PSK, APIClient, APIVersion, BluetoothProxyFeature, @@ -16,6 +17,7 @@ EntityState, HomeassistantServiceCall, LogLevel, + OutgoingConnectionServer, ReconnectLogic, UserService, VoiceAssistantAnnounceFinished, @@ -94,6 +96,21 @@ def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" +@pytest.fixture(autouse=True) +def mock_outgoing_connection_server() -> Generator[MagicMock]: + """Patch the shared dial-in listener so tests never bind a real socket.""" + server = MagicMock(spec=OutgoingConnectionServer) + # A real unregister callback returns None + server.register.return_value.return_value = None + with patch( + "homeassistant.components.esphome.outgoing_connection.OutgoingConnectionServer", + return_value=server, + ) as server_class: + # Tests assert the singleton builds exactly one server + server.constructor = server_class + yield server + + @pytest.fixture(autouse=True) def esphome_mock_async_zeroconf(mock_async_zeroconf: MagicMock) -> None: """Auto mock zeroconf.""" @@ -186,6 +203,7 @@ def mock_constructor( noise_psk: str | None = None, expected_name: str | None = None, timezone: str | None = None, + outgoing_connection_target: bool = False, ) -> None: """Fake the client constructor.""" mock_client.host = address @@ -194,6 +212,12 @@ def mock_constructor( mock_client.zeroconf_instance = zeroconf_instance mock_client.noise_psk = noise_psk mock_client.timezone = timezone + # Mirror the real constructor's gate on a real key + mock_client.outgoing_connection_target = ( + outgoing_connection_target + and bool(noise_psk) + and noise_psk != ZERO_NOISE_PSK + ) return mock_client mock_client.side_effect = mock_constructor diff --git a/tests/components/esphome/test_init.py b/tests/components/esphome/test_init.py index 9a1af3b3a28cf7..09db0e8da4b2d9 100644 --- a/tests/components/esphome/test_init.py +++ b/tests/components/esphome/test_init.py @@ -56,6 +56,8 @@ async def test_remove_entry_clears_dynamic_encryption_key( mock_client.connect.assert_called_once() mock_client.noise_encryption_set_key.assert_called_once_with(b"") mock_client.disconnect.assert_called_once() + # The connection that wipes the key must not become a dial-back target + assert mock_client.outgoing_connection_target is False assert await storage.async_get_key(mock_config_entry.unique_id) is None diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 8113a9a0e394f8..6f12a79ce46fa2 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -3249,6 +3249,7 @@ def mock_provisioning_client(mock_client: APIClient) -> Generator[Mock]: def _api_client(*args: Any, **kwargs: Any) -> Mock: if kwargs.get("noise_psk") == ZERO_NOISE_PSK: + client.outgoing_connection_target = kwargs["outgoing_connection_target"] return client return mock_client(*args, **kwargs) @@ -3317,6 +3318,8 @@ async def test_dynamic_encryption_key_provisioned_over_zero_psk( ) mock_client.noise_encryption_set_key.assert_not_called() mock_provisioning_client.disconnect.assert_called_with(force=True) + # The key exchange session must not become a dial-back target + assert mock_provisioning_client.outgoing_connection_target is False # Entry and storage were updated assert entry.data[CONF_NOISE_PSK] == expected_key diff --git a/tests/components/esphome/test_outgoing_connection.py b/tests/components/esphome/test_outgoing_connection.py new file mode 100644 index 00000000000000..64fce97f489b4e --- /dev/null +++ b/tests/components/esphome/test_outgoing_connection.py @@ -0,0 +1,197 @@ +"""Tests for device-initiated outgoing connections.""" + +import asyncio +from unittest.mock import MagicMock, patch + +from aioesphomeapi import ZERO_NOISE_PSK, APIClient +import pytest + +from homeassistant.components.esphome.const import CONF_NOISE_PSK, DOMAIN +from homeassistant.components.esphome.manager import ESPHomeManager +from homeassistant.components.esphome.outgoing_connection import ( + async_register_outgoing_target, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import CoreState, HomeAssistant + +from . import VALID_NOISE_PSK +from .conftest import MockESPHomeDeviceType + +from tests.common import MockConfigEntry + +MAC = "11:22:33:44:55:aa" + + +def _make_entry( + *, + noise_psk: str | None = VALID_NOISE_PSK, + unique_id: str = MAC, +) -> MockConfigEntry: + data = {CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: ""} + if noise_psk is not None: + data[CONF_NOISE_PSK] = noise_psk + return MockConfigEntry(domain=DOMAIN, data=data, unique_id=unique_id) + + +async def test_outgoing_connection_registration( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_outgoing_connection_server: MagicMock, +) -> None: + """An encrypted entry registers with the shared listener.""" + entry = _make_entry() + entry.add_to_hass(hass) + await mock_esphome_device(mock_client=mock_client, entry=entry, device_info={}) + await hass.async_block_till_done() + + assert mock_outgoing_connection_server.register.call_args.args[0] == MAC + # The client declares itself a dial-back target in its hello + assert mock_client.outgoing_connection_target is True + + # Unloading the entry removes its route; the library owns the rest + unregister = mock_outgoing_connection_server.register.return_value + unregister.assert_not_called() + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + unregister.assert_called() + + +@pytest.mark.parametrize("noise_psk", [None, "", ZERO_NOISE_PSK]) +async def test_outgoing_connection_requires_noise_psk( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_outgoing_connection_server: MagicMock, + noise_psk: str | None, +) -> None: + """No real key (missing, empty, or the zero provisioning PSK), no route.""" + entry = _make_entry(noise_psk=noise_psk) + entry.add_to_hass(hass) + await mock_esphome_device(mock_client=mock_client, entry=entry, device_info={}) + await hass.async_block_till_done() + + mock_outgoing_connection_server.register.assert_not_called() + assert mock_client.outgoing_connection_target is False + + +async def test_outgoing_connection_shared_listener( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_outgoing_connection_server: MagicMock, +) -> None: + """Two entries share the one listener; each registers its own MAC.""" + entry = _make_entry() + entry.add_to_hass(hass) + await mock_esphome_device(mock_client=mock_client, entry=entry, device_info={}) + entry2 = _make_entry(unique_id="aa:bb:cc:dd:ee:01") + entry2.add_to_hass(hass) + await mock_esphome_device( + mock_client=mock_client, + entry=entry2, + device_info={"mac_address": "AA:BB:CC:DD:EE:01", "name": "test2"}, + ) + await hass.async_block_till_done() + + assert mock_outgoing_connection_server.register.call_count == 2 + macs = [ + call.args[0] for call in mock_outgoing_connection_server.register.call_args_list + ] + assert macs == [MAC, "aa:bb:cc:dd:ee:01"] + # One server for both entries; a lost @singleton would build two + assert mock_outgoing_connection_server.constructor.call_count == 1 + + +async def test_outgoing_connection_requires_mac_unique_id( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_outgoing_connection_server: MagicMock, +) -> None: + """A pre-2023 non-MAC unique id gets no route and declares no flag.""" + entry = _make_entry(unique_id="my-old-device") + entry.add_to_hass(hass) + await mock_esphome_device(mock_client=mock_client, entry=entry, device_info={}) + await hass.async_block_till_done() + + mock_outgoing_connection_server.register.assert_not_called() + assert mock_client.outgoing_connection_target is False + + +async def test_outgoing_connection_stops_on_hass_stop( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_outgoing_connection_server: MagicMock, +) -> None: + """The shared listener is closed when Home Assistant stops.""" + entry = _make_entry() + entry.add_to_hass(hass) + await mock_esphome_device(mock_client=mock_client, entry=entry, device_info={}) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + mock_outgoing_connection_server.close.assert_called_once() + + +async def test_outgoing_connection_not_started_during_shutdown( + hass: HomeAssistant, + mock_outgoing_connection_server: MagicMock, +) -> None: + """No route is registered once Home Assistant is stopping.""" + hass.set_state(CoreState.stopping) + assert async_register_outgoing_target(hass, MAC, MagicMock()) is None + mock_outgoing_connection_server.register.assert_not_called() + + +async def test_outgoing_connection_register_error_does_not_fail_setup( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_outgoing_connection_server: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A raising register is contained; the entry still loads.""" + mock_outgoing_connection_server.register.side_effect = RuntimeError("boom") + entry = _make_entry() + entry.add_to_hass(hass) + await mock_esphome_device(mock_client=mock_client, entry=entry, device_info={}) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert "Could not set up dial-in routing" in caplog.text + + +async def test_outgoing_connection_route_removed_on_failed_setup( + hass: HomeAssistant, + mock_client: APIClient, + mock_outgoing_connection_server: MagicMock, +) -> None: + """A setup that fails after the route is registered drains it. + + Registration is the last step of async_start that can raise; a failure + after it is the Bluetooth scanner wait being cancelled. + """ + entry = _make_entry() + entry.add_to_hass(hass) + real_start = ESPHomeManager.async_start + + async def start_then_cancel(self: ESPHomeManager) -> None: + await real_start(self) + raise asyncio.CancelledError + + with patch.object(ESPHomeManager, "async_start", start_then_cancel): + assert not await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_ERROR + mock_outgoing_connection_server.register.assert_called_once() + mock_outgoing_connection_server.register.return_value.assert_called_once() From 825f0f4bfffe5f90471c09b53256456f1ab88ff9 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 10 Sep 2026 18:58:24 +0200 Subject: [PATCH 04/21] Bump reolink_aio to 0.21.16 (#181861) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index ec67e5fb06875e..d31620d0e526a4 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -20,5 +20,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.21.15"] + "requirements": ["reolink-aio==0.21.16"] } diff --git a/requirements_all.txt b/requirements_all.txt index 304253cb87f727..c872fa47f157d6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2977,7 +2977,7 @@ renault-api==0.5.13 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.21.15 +reolink-aio==0.21.16 # homeassistant.components.radio_frequency rf-protocols==4.3.0 From 2baa5524455a3e7c8e71c983ff8348d590a968fb Mon Sep 17 00:00:00 2001 From: Christian Lackas Date: Thu, 10 Sep 2026 19:42:06 +0200 Subject: [PATCH 05/21] Bump PyViCare to 2.62.1 (#181830) --- homeassistant/components/vicare/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index 3ccc68df7b2106..73bef717a65a5f 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -13,5 +13,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.62.0"] + "requirements": ["PyViCare==2.62.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index c872fa47f157d6..8c885f22f4ab11 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -102,7 +102,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.8.3 # homeassistant.components.vicare -PyViCare==2.62.0 +PyViCare==2.62.1 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 From bd98f696093813930e9ff5393b9a8b97fc5c3f5f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:42:45 +0200 Subject: [PATCH 06/21] Use snapshot_platform helper in avea light tests (#181822) --- .../components/avea/snapshots/test_light.ambr | 64 +++++++++++++++++++ tests/components/avea/test_light.py | 38 +++++------ 2 files changed, 83 insertions(+), 19 deletions(-) create mode 100644 tests/components/avea/snapshots/test_light.ambr diff --git a/tests/components/avea/snapshots/test_light.ambr b/tests/components/avea/snapshots/test_light.ambr new file mode 100644 index 00000000000000..f4d16c7d98b1b7 --- /dev/null +++ b/tests/components/avea/snapshots/test_light.ambr @@ -0,0 +1,64 @@ +# serializer version: 1 +# name: test_all_entities[light.bedroom-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.bedroom', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'avea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:BB:CC:DD:EE:FF', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[light.bedroom-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : None, + : None, + : 'Bedroom', + : None, + : None, + : list([ + , + ]), + : , + : None, + }), + 'context': , + 'entity_id': 'light.bedroom', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/avea/test_light.py b/tests/components/avea/test_light.py index 6f87a3b432fa0e..4275ba55f49d17 100644 --- a/tests/components/avea/test_light.py +++ b/tests/components/avea/test_light.py @@ -6,21 +6,21 @@ from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.avea.const import UNKNOWN_NAME from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_HS_COLOR, - ATTR_SUPPORTED_COLOR_MODES, - ColorMode, + LightEntityStateAttribute, ) from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON 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 . import AVEA_DISCOVERY_INFO, AVEA_FIRMWARE_VERSION, AVEA_SERIAL_NUMBER -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.fixture @@ -63,16 +63,16 @@ async def setup_integration( yield mock_bulb -async def test_init_state( +@pytest.mark.usefixtures("setup_integration") +async def test_all_entities( hass: HomeAssistant, - setup_integration: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: - """Test the initial state.""" - state = hass.states.get("light.bedroom") - assert state is not None - assert state.state == STATE_OFF - assert state.name == "Bedroom" - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.HS] + """Test all entities.""" + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) async def test_device_info( @@ -257,7 +257,7 @@ async def test_turn_on_restores_last_brightness( state = hass.states.get("light.bedroom") assert state is not None - assert state.attributes[ATTR_BRIGHTNESS] == 200 + assert state.attributes[LightEntityStateAttribute.BRIGHTNESS] == 200 bulb.set_brightness.reset_mock() await hass.services.async_call( @@ -280,7 +280,7 @@ async def test_turn_on_restores_last_brightness( state = hass.states.get("light.bedroom") assert state is not None assert state.state == STATE_OFF - assert state.attributes[ATTR_BRIGHTNESS] is None + assert state.attributes[LightEntityStateAttribute.BRIGHTNESS] is None bulb.set_brightness.reset_mock() await hass.services.async_call( @@ -299,7 +299,7 @@ async def test_update_state( state = hass.states.get("light.bedroom") assert state is not None assert state.state == STATE_OFF - assert state.attributes[ATTR_BRIGHTNESS] is None + assert state.attributes[LightEntityStateAttribute.BRIGHTNESS] is None bulb = setup_integration bulb.reset_mock() @@ -328,8 +328,8 @@ async def test_update_state( state = hass.states.get("light.bedroom") assert state is not None assert state.state == STATE_ON - assert state.attributes[ATTR_BRIGHTNESS] == 128 - assert state.attributes[ATTR_HS_COLOR] == (120.0, 100.0) + assert state.attributes[LightEntityStateAttribute.BRIGHTNESS] == 128 + assert state.attributes[LightEntityStateAttribute.HS_COLOR] == (120.0, 100.0) async def test_update_state_uses_cached_values_when_connect_fails( @@ -354,5 +354,5 @@ async def test_update_state_uses_cached_values_when_connect_fails( state = hass.states.get("light.bedroom") assert state is not None assert state.state == STATE_ON - assert state.attributes[ATTR_BRIGHTNESS] == 128 - assert state.attributes[ATTR_HS_COLOR] == (120.0, 100.0) + assert state.attributes[LightEntityStateAttribute.BRIGHTNESS] == 128 + assert state.attributes[LightEntityStateAttribute.HS_COLOR] == (120.0, 100.0) From 7e94b9aa053575381feb03c61472b5f83a02b170 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:43:22 +0200 Subject: [PATCH 07/21] Use snapshot_platform helper in adax climate tests (#181820) --- .../adax/snapshots/test_climate.ambr | 139 ++++++++++++++++++ tests/components/adax/test_climate.py | 105 +++++++------ 2 files changed, 202 insertions(+), 42 deletions(-) create mode 100644 tests/components/adax/snapshots/test_climate.ambr diff --git a/tests/components/adax/snapshots/test_climate.ambr b/tests/components/adax/snapshots/test_climate.ambr new file mode 100644 index 00000000000000..b0898c1f64eed0 --- /dev/null +++ b/tests/components/adax/snapshots/test_climate.ambr @@ -0,0 +1,139 @@ +# serializer version: 1 +# name: test_all_entities_cloud[climate.room_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + , + ]), + : 35, + : 5, + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.room_1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Room 1', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:radiator', + 'original_name': 'Room 1', + 'platform': 'adax', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities_cloud[climate.room_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 15, + : 'Room 1', + : list([ + , + , + ]), + : 'mdi:radiator', + : 35, + : 5, + : , + : 1, + : 20, + }), + 'context': , + 'entity_id': 'climate.room_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_all_entities_local[climate.mock_title-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + , + ]), + : 35, + : 5, + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.mock_title', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:radiator-off', + 'original_name': None, + 'platform': 'adax', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '11:22:33:44:55:66', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities_local[climate.mock_title-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 15, + : 'Mock Title', + : list([ + , + , + ]), + : 'mdi:radiator', + : 35, + : 5, + : , + : 1, + : 20, + }), + 'context': , + 'entity_id': 'climate.mock_title', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/adax/test_climate.py b/tests/components/adax/test_climate.py index d93db72e722d73..9e0105c9377bcb 100644 --- a/tests/components/adax/test_climate.py +++ b/tests/components/adax/test_climate.py @@ -1,24 +1,79 @@ """Test Adax climate entity.""" +from collections.abc import Generator +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + from homeassistant.components.adax.const import SCAN_INTERVAL from homeassistant.components.climate import ( - ATTR_CURRENT_TEMPERATURE, ATTR_HVAC_MODE, DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, SERVICE_SET_TEMPERATURE, + ClimateEntityStateAttribute, HVACMode, ) -from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_UNAVAILABLE +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + STATE_UNAVAILABLE, + Platform, +) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import setup_integration -from .conftest import CLOUD_DEVICE_DATA -from tests.common import AsyncMock, MockConfigEntry, async_fire_time_changed +from tests.common import ( + AsyncMock, + MockConfigEntry, + async_fire_time_changed, + snapshot_platform, +) from tests.test_setup import FrozenDateTimeFactory +@pytest.fixture(autouse=True) +def override_platforms() -> Generator[None]: + """Override PLATFORMS.""" + with patch("homeassistant.components.adax.PLATFORMS", [Platform.CLIMATE]): + yield + + +async def test_all_entities_cloud( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_cloud_config_entry: MockConfigEntry, + mock_adax_cloud: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test all entities for a cloud connection.""" + await setup_integration(hass, mock_cloud_config_entry) + mock_adax_cloud.fetch_rooms_info.assert_called_once() + + await snapshot_platform( + hass, entity_registry, snapshot, mock_cloud_config_entry.entry_id + ) + + +async def test_all_entities_local( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_local_config_entry: MockConfigEntry, + mock_adax_local: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test all entities for a local connection.""" + await setup_integration(hass, mock_local_config_entry) + mock_adax_local.get_status.assert_called_once() + + await snapshot_platform( + hass, entity_registry, snapshot, mock_local_config_entry.entry_id + ) + + async def test_climate_cloud( hass: HomeAssistant, freezer: FrozenDateTimeFactory, @@ -29,21 +84,8 @@ async def test_climate_cloud( await setup_integration(hass, mock_cloud_config_entry) mock_adax_cloud.fetch_rooms_info.assert_called_once() - assert len(hass.states.async_entity_ids(CLIMATE_DOMAIN)) == 1 entity_id = hass.states.async_entity_ids(CLIMATE_DOMAIN)[0] - state = hass.states.get(entity_id) - - assert state - assert state.state == HVACMode.HEAT - assert ( - state.attributes[ATTR_TEMPERATURE] == CLOUD_DEVICE_DATA[0]["targetTemperature"] - ) - assert ( - state.attributes[ATTR_CURRENT_TEMPERATURE] - == CLOUD_DEVICE_DATA[0]["temperature"] - ) - mock_adax_cloud.fetch_rooms_info.side_effect = Exception() freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) @@ -64,7 +106,6 @@ async def test_climate_local( await setup_integration(hass, mock_local_config_entry) mock_adax_local.get_status.assert_called_once() - assert len(hass.states.async_entity_ids(CLIMATE_DOMAIN)) == 1 entity_id = hass.states.async_entity_ids(CLIMATE_DOMAIN)[0] freezer.tick(SCAN_INTERVAL) @@ -74,8 +115,6 @@ async def test_climate_local( state = hass.states.get(entity_id) assert state assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == 20 - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 15 mock_adax_local.get_status.side_effect = Exception() freezer.tick(SCAN_INTERVAL) @@ -87,24 +126,6 @@ async def test_climate_local( assert state.state == STATE_UNAVAILABLE -async def test_climate_local_initial_state_from_first_refresh( - hass: HomeAssistant, - mock_local_config_entry: MockConfigEntry, - mock_adax_local: AsyncMock, -) -> None: - """Test that local climate state is initialized from first refresh data.""" - await setup_integration(hass, mock_local_config_entry) - - assert len(hass.states.async_entity_ids(CLIMATE_DOMAIN)) == 1 - entity_id = hass.states.async_entity_ids(CLIMATE_DOMAIN)[0] - - state = hass.states.get(entity_id) - assert state - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == 20 - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 15 - - async def test_climate_local_initial_state_off_from_first_refresh( hass: HomeAssistant, mock_local_config_entry: MockConfigEntry, @@ -121,8 +142,8 @@ async def test_climate_local_initial_state_off_from_first_refresh( state = hass.states.get(entity_id) assert state assert state.state == HVACMode.OFF - assert state.attributes[ATTR_TEMPERATURE] == 5 - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 15 + assert state.attributes[ClimateEntityStateAttribute.TARGET_TEMPERATURE] == 5 + assert state.attributes[ClimateEntityStateAttribute.CURRENT_TEMPERATURE] == 15 async def test_climate_local_set_hvac_mode_updates_state_immediately( @@ -203,7 +224,7 @@ async def test_climate_local_set_temperature_when_off_does_not_change_hvac_mode( state = hass.states.get(entity_id) assert state assert state.state == HVACMode.OFF - assert state.attributes[ATTR_TEMPERATURE] == 23 + assert state.attributes[ClimateEntityStateAttribute.TARGET_TEMPERATURE] == 23 async def test_climate_local_set_temperature_when_heat_calls_device( @@ -233,4 +254,4 @@ async def test_climate_local_set_temperature_when_heat_calls_device( state = hass.states.get(entity_id) assert state assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == 24 + assert state.attributes[ClimateEntityStateAttribute.TARGET_TEMPERATURE] == 24 From 432ebadc17c4961dec6ee2c97a9a684c5c9bb40a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 10 Sep 2026 13:44:36 -0400 Subject: [PATCH 08/21] Stream usage prediction events instead of loading them into memory (#181770) Co-authored-by: Claude Fable 5.1 --- .../usage_prediction/common_control.py | 172 +++++++++--------- .../usage_prediction/test_common_control.py | 81 +++++++++ 2 files changed, 162 insertions(+), 91 deletions(-) diff --git a/homeassistant/components/usage_prediction/common_control.py b/homeassistant/components/usage_prediction/common_control.py index 15d17dceaaef8c..d3f1da724035d8 100644 --- a/homeassistant/components/usage_prediction/common_control.py +++ b/homeassistant/components/usage_prediction/common_control.py @@ -1,15 +1,12 @@ """Code to generate common control usage patterns.""" from collections import Counter -from collections.abc import Callable, Sequence from datetime import datetime, timedelta from functools import cache import logging from typing import Any, Literal, cast from sqlalchemy import select -from sqlalchemy.engine.row import Row -from sqlalchemy.orm import Session from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.db_schema import EventData, Events, EventTypes @@ -30,6 +27,9 @@ RESULTS_TO_INCLUDE = 8 +# Rows fetched per round trip while streaming the events query +QUERY_YIELD_PER = 4096 + # List of domains for which we want to track usage ALLOWED_DOMAINS = { # Entity platforms @@ -94,83 +94,16 @@ async def async_predict_common_control( recorder = get_instance(hass) ent_reg = er.async_get(hass) - # Execute the database operation in the recorder's executor - data = await recorder.async_add_executor_job( - _fetch_with_session, hass, _fetch_and_process_data, ent_reg, user_id - ) - # Prepare a dictionary to track results - results: dict[str, Counter[str]] = { - time_cat: Counter() for time_cat in TIME_CATEGORIES + allowed_entities = { + entity_id + for entity_id in hass.states.async_entity_ids(ALLOWED_DOMAINS) + if not ((entry := ent_reg.async_get(entity_id)) and entry.hidden) } - allowed_entities = set(hass.states.async_entity_ids(ALLOWED_DOMAINS)) - hidden_entities: set[str] = set() - - # Keep track of contexts that we processed so that we will only process - # the first service call in a context, and not subsequent calls. - context_processed: set[bytes] = set() - # Execute the query - context_id: bytes - time_fired_ts: float - shared_data: str | None - local_time_zone = dt_util.get_default_time_zone() - for context_id, time_fired_ts, shared_data in data: - # Skip if we have already processed an event that was part of this context - if context_id in context_processed: - continue - - # Mark this context as processed - context_processed.add(context_id) - - # Parse the event data - if not time_fired_ts or not shared_data: - continue - - try: - event_data = json_loads_object(shared_data) - except (ValueError, TypeError) as err: - _LOGGER.debug("Failed to parse event data: %s", err) - continue - - # Empty event data, skipping - if not event_data: - continue - - service_data = cast(dict[str, Any] | None, event_data.get("service_data")) - - # No service data found, skipping - if not service_data: - continue - - entity_ids: str | list[str] | None = service_data.get("entity_id") - - # No entity IDs found, skip this event - if entity_ids is None: - continue - - if not isinstance(entity_ids, list): - entity_ids = [entity_ids] - - # Convert to local time for time category determination - period = time_category( - datetime.fromtimestamp(time_fired_ts, local_time_zone).hour - ) - period_results = results[period] - - # Count entity usage - for entity_id in entity_ids: - if entity_id not in allowed_entities or entity_id in hidden_entities: - continue - - if ( - entity_id not in period_results - and (entry := ent_reg.async_get(entity_id)) - and entry.hidden - ): - hidden_entities.add(entity_id) - continue - - period_results[entity_id] += 1 + # Execute the database operation in the recorder's executor + results = await recorder.async_add_executor_job( + _fetch_and_process_data, hass, user_id, allowed_entities + ) return EntityUsagePredictions( morning=[ @@ -190,9 +123,13 @@ async def async_predict_common_control( def _fetch_and_process_data( - session: Session, ent_reg: er.EntityRegistry, user_id: str -) -> Sequence[Row[tuple[bytes | None, float | None, str | None]]]: - """Fetch and process service call events from the database.""" + hass: HomeAssistant, user_id: str, allowed_entities: set[str] +) -> dict[str, Counter[str]]: + """Count service call events per entity and time category. + + Rows are streamed and reduced here so only the counters cross the + executor boundary; a busy user can have millions of matching events. + """ thirty_days_ago_ts = (dt_util.utcnow() - timedelta(days=30)).timestamp() user_id_bytes = uuid_hex_to_bytes_or_none(user_id) if not user_id_bytes: @@ -213,16 +150,69 @@ def _fetch_and_process_data( .where(EventTypes.event_type == "call_service") .order_by(Events.time_fired_ts) ) - return session.connection().execute(query).all() + # Prepare a dictionary to track results + results: dict[str, Counter[str]] = { + time_cat: Counter() for time_cat in TIME_CATEGORIES + } + + # Keep track of contexts that we processed so that we will only process + # the first service call in a context, and not subsequent calls. + context_processed: set[bytes] = set() + local_time_zone = dt_util.get_default_time_zone() -def _fetch_with_session( - hass: HomeAssistant, - fetch_func: Callable[ - [Session], Sequence[Row[tuple[bytes | None, float | None, str | None]]] - ], - *args: object, -) -> Sequence[Row[tuple[bytes | None, float | None, str | None]]]: - """Execute a fetch function with a database session.""" + context_id: bytes + time_fired_ts: float + shared_data: str | None with session_scope(hass=hass, read_only=True) as session: - return fetch_func(session, *args) + rows = session.connection().execute(query).yield_per(QUERY_YIELD_PER) + for context_id, time_fired_ts, shared_data in rows: + # Skip if we have already processed an event that was part of this context + if context_id in context_processed: + continue + + # Mark this context as processed + context_processed.add(context_id) + + # Parse the event data + if not time_fired_ts or not shared_data: + continue + + try: + event_data = json_loads_object(shared_data) + except (ValueError, TypeError) as err: + _LOGGER.debug("Failed to parse event data: %s", err) + continue + + # Empty event data, skipping + if not event_data: + continue + + service_data = cast(dict[str, Any] | None, event_data.get("service_data")) + + # No service data found, skipping + if not service_data: + continue + + entity_ids: str | list[str] | None = service_data.get("entity_id") + + # No entity IDs found, skip this event + if entity_ids is None: + continue + + if not isinstance(entity_ids, list): + entity_ids = [entity_ids] + + # Convert to local time for time category determination + period_results = results[ + time_category( + datetime.fromtimestamp(time_fired_ts, local_time_zone).hour + ) + ] + + # Count entity usage + for entity_id in entity_ids: + if entity_id in allowed_entities: + period_results[entity_id] += 1 + + return results diff --git a/tests/components/usage_prediction/test_common_control.py b/tests/components/usage_prediction/test_common_control.py index ef65cd831c0f98..bb6b1a41bf3918 100644 --- a/tests/components/usage_prediction/test_common_control.py +++ b/tests/components/usage_prediction/test_common_control.py @@ -1,5 +1,6 @@ """Test the common control usage prediction.""" +from typing import Any from unittest.mock import patch import uuid @@ -409,3 +410,83 @@ async def test_different_users_separated(hass: HomeAssistant) -> None: evening=[], night=["light.user2_light"], ) + + +@pytest.mark.usefixtures("recorder_mock") +@pytest.mark.parametrize( + "event_data", + [ + pytest.param({}, id="no_event_data"), + pytest.param({"domain": "light", "service": "turn_on"}, id="no_service_data"), + pytest.param( + { + "domain": "light", + "service": "turn_on", + "service_data": {"brightness": 255}, + }, + id="no_entity_id", + ), + ], +) +async def test_events_without_entity_ids_ignored( + hass: HomeAssistant, event_data: dict[str, Any] +) -> None: + """Test that service call events without entity IDs are skipped.""" + user_id = str(uuid.uuid4()) + + hass.states.async_set("light.kitchen", "off") + + with freeze_time("2023-07-01 10:00:00"): + hass.bus.async_fire( + EVENT_CALL_SERVICE, event_data, context=Context(user_id=user_id) + ) + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + + with freeze_time("2023-07-02 10:00:00"): + results = await async_predict_common_control(hass, user_id) + + assert results == EntityUsagePredictions() + + +@pytest.mark.usefixtures("recorder_mock") +@pytest.mark.parametrize( + "json_loads_kwargs", + [ + pytest.param({"side_effect": ValueError("bad json")}, id="invalid_json"), + pytest.param({"return_value": {}}, id="empty_object"), + ], +) +async def test_unusable_event_data_ignored( + hass: HomeAssistant, json_loads_kwargs: dict[str, Any] +) -> None: + """Test that events whose stored data cannot be used are skipped.""" + user_id = str(uuid.uuid4()) + + hass.states.async_set("light.kitchen", "off") + + with freeze_time("2023-07-01 10:00:00"): + hass.bus.async_fire( + EVENT_CALL_SERVICE, + { + "domain": "light", + "service": "turn_on", + "service_data": {"entity_id": "light.kitchen"}, + }, + context=Context(user_id=user_id), + ) + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + + with ( + freeze_time("2023-07-02 10:00:00"), + patch( + "homeassistant.components.usage_prediction.common_control.json_loads_object", + **json_loads_kwargs, + ), + ): + results = await async_predict_common_control(hass, user_id) + + assert results == EntityUsagePredictions() From b3c2b5dc3a7f9ae88a6943ffe37929305717b727 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 10 Sep 2026 19:51:38 +0200 Subject: [PATCH 09/21] Add property is_composite_device to DeviceEntry base class (#181780) --- .../components/device_automation/__init__.py | 8 +- homeassistant/helpers/device_registry.py | 20 ++++ .../components/device_automation/test_init.py | 93 +++++++++++++++++++ tests/helpers/test_device_registry.py | 38 ++++++++ 4 files changed, 158 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index 06af33fdbefeea..8c405c12dc9282 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -251,7 +251,13 @@ async def async_get_device_automations( combined_results[device_id] = [] if (device := device_registry.async_get(device_id)) is None: raise DeviceNotFound - for entry_id in device.config_entries: + if device.is_composite_device: + # A restored composite has no single owning config entry; the union + # of the split devices' config entries covers every owning domain. + entry_ids = device.config_entries + else: + entry_ids = {device.config_entry_id} + for entry_id in entry_ids: if config_entry := hass.config_entries.async_get_entry(entry_id): domain_devices.setdefault(config_entry.domain, set()).add(device_id) for domain in device_entities_domains.get(device_id, []): diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 003b029d9eb773..0144fbe94c6400 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -426,6 +426,16 @@ def primary_config_entry(self) -> str: """ return self.config_entry_id + @property + def is_composite_device(self) -> bool: + """Return if this entry is a restored composite device. + + A restored composite is synthesized by async_get for a pre-migration + composite device id and never stored; a plain main or child device is + never a composite. + """ + return False + @property def disabled(self) -> bool: """Return if entry is disabled.""" @@ -526,6 +536,16 @@ def config_entries_subentries(self) -> dict[str, set[str | None]]: } return {self.config_entry_id: {self.config_subentry_id}} + @property + @override + def is_composite_device(self) -> bool: + """Return if this entry is a restored composite device. + + A restored composite is synthesized by async_get for a pre-migration + composite device id and never stored. + """ + return self._composite_subentries is not None + @property @override def dict_repr(self) -> dict[str, Any]: diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index 7fb533b9f4ba90..4cc8658f3e0b8e 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -1889,3 +1889,96 @@ async def test_validate_config_rewrites_composite_device_id( DeviceAutomationType.TRIGGER, ) assert validated["device_id"] == device_fake.id + + +def _mock_device_trigger_platform(hass: HomeAssistant, domain: str) -> None: + """Mock a device_trigger platform returning one trigger for the queried device.""" + + async def _async_get_triggers( + hass: HomeAssistant, device_id: str + ) -> list[dict[str, str]]: + """List device triggers.""" + return [ + { + "platform": "device", + "domain": domain, + "type": "changed_states", + "device_id": device_id, + } + ] + + mock_platform( + hass, + f"{domain}.device_trigger", + Mock(async_get_triggers=_async_get_triggers, spec=["async_get_triggers"]), + ) + + +async def test_async_get_device_automations_composite_device( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test a composite id is queried for the domains of all its splits' config entries. + + A split device is queried only for the domain of its own config entry. + """ + await async_setup_component(hass, DOMAIN, {}) + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + _mock_device_trigger_platform(hass, "domain_a") + _mock_device_trigger_platform(hass, "domain_b") + + device_a = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")} + ) + device_b = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers={("domain_b", "1")} + ) + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry._devices[device_a.id] = attr.evolve( + device_a, composite_device_id=COMPOSITE_ID + ) + device_registry._devices[device_b.id] = attr.evolve( + device_b, composite_device_id=COMPOSITE_ID + ) + assert device_registry.async_get(COMPOSITE_ID).is_composite_device is True + assert device_registry.async_get(device_a.id).is_composite_device is False + + result = await device_automation.async_get_device_automations( + hass, + device_automation.DeviceAutomationType.TRIGGER, + [COMPOSITE_ID, device_a.id], + ) + + # Results are keyed by the requested ids, the composite id included + assert set(result) == {COMPOSITE_ID, device_a.id} + # The composite is queried for both of its splits' config entry domains + assert result[COMPOSITE_ID] == unordered( + [ + { + "platform": "device", + "domain": "domain_a", + "type": "changed_states", + "device_id": COMPOSITE_ID, + "metadata": {}, + }, + { + "platform": "device", + "domain": "domain_b", + "type": "changed_states", + "device_id": COMPOSITE_ID, + "metadata": {}, + }, + ] + ) + # A split is queried for its own config entry domain only + assert result[device_a.id] == [ + { + "platform": "device", + "domain": "domain_a", + "type": "changed_states", + "device_id": device_a.id, + "metadata": {}, + } + ] diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index a5bd2990e354a1..d908c75b9d1ca2 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -10007,6 +10007,44 @@ async def test_async_get_returns_restored_composite( ) +async def test_is_composite_device_main_and_child( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """A plain main device and a child device are not composites.""" + parent, child_device = _create_parent_and_child( + device_registry, mock_config_entry.entry_id + ) + + assert parent.is_composite_device is False + assert child_device.is_composite_device is False + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_is_composite_device_restored_composite( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A restored composite reports is_composite_device True.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b) + + dr.async_setup(hass) + await dr.async_load(hass) + device_registry = dr.async_get(hass) + + composite = device_registry.async_get(COMPOSITE_ID) + assert composite is not None + assert composite.is_composite_device is True + # The split devices the composite was restored from are not composites + split_a = _get_device_for_config_entry( + device_registry, entry_a.entry_id, identifiers={("domain_a", "1")} + ) + assert split_a.is_composite_device is False + + @pytest.mark.parametrize("load_registries", [False]) async def test_restored_composite_preserves_primary_config_entry( hass: HomeAssistant, hass_storage: dict[str, Any] From 5ffb0d10d53194d158b9f8e4361dc2200cd1ed76 Mon Sep 17 00:00:00 2001 From: EnjoyingM <6302356+EnjoyingM@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:53:22 +0200 Subject: [PATCH 10/21] Wolflink: Add quality scale (#174502) Co-authored-by: EnjoyingM <6302356+mtielen@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/wolflink/manifest.json | 1 + .../components/wolflink/quality_scale.yaml | 102 ++++++++++++++++++ .../components/wolflink/strings.json | 4 + script/hassfest/quality_scale.py | 2 - 4 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/wolflink/quality_scale.yaml diff --git a/homeassistant/components/wolflink/manifest.json b/homeassistant/components/wolflink/manifest.json index b6fba7ed9a7c28..11fa87edb0dbe4 100644 --- a/homeassistant/components/wolflink/manifest.json +++ b/homeassistant/components/wolflink/manifest.json @@ -7,5 +7,6 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["wolf_comm"], + "quality_scale": "bronze", "requirements": ["wolf-comm==0.0.52"] } diff --git a/homeassistant/components/wolflink/quality_scale.yaml b/homeassistant/components/wolflink/quality_scale.yaml new file mode 100644 index 00000000000000..76e13a6f891a67 --- /dev/null +++ b/homeassistant/components/wolflink/quality_scale.yaml @@ -0,0 +1,102 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom actions are defined. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom actions are defined. + docs-conditions: + status: exempt + comment: | + This integration does not have any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: | + This integration does not have any triggers. + entity-event-setup: + status: exempt + comment: | + The integration doesn't perform any subscriptions within its entities. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + No custom actions are defined. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + The integration has no options to configure. + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: + status: todo + comment: | + Extract repeated patching logic into a reusable test fixture. + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + Devices are retrieved from the cloud account, not discovered on the network. + discovery: + status: exempt + comment: | + Devices are retrieved from the cloud account, not discovered on the network. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: todo + comment: | + Investigate coordinator data retention: does omitting a value remove it from coordinator.data? + entity-category: todo + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: + status: todo + comment: | + Add English entity state translations for wolflink sensors. + Clarify/fix the status classification for the 'default' state. + Review extra state attributes and remove if no longer needed. + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + Read-only integration; no repairs are possible. + stale-devices: todo + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/wolflink/strings.json b/homeassistant/components/wolflink/strings.json index a01d5a66d3b72f..4cefd8ad1cc772 100644 --- a/homeassistant/components/wolflink/strings.json +++ b/homeassistant/components/wolflink/strings.json @@ -15,6 +15,10 @@ "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" }, + "data_description": { + "password": "The password for your Wolf SmartSet account.", + "username": "The username for your Wolf SmartSet account." + }, "title": "WOLF SmartSet connection" } } diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 1eaf91a2652cb7..9f356d29da0d02 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -977,7 +977,6 @@ class Rule: "withings", "wiz", "wmspro", - "wolflink", "workday", "worldclock", "worldtidesinfo", @@ -1939,7 +1938,6 @@ class Rule: "withings", "wiz", "wmspro", - "wolflink", "workday", "worldclock", "worldtidesinfo", From a9337d2a8f41b74d8b7ec9d643090658deb1813d Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 10 Sep 2026 19:59:48 +0200 Subject: [PATCH 11/21] Add coroutines to Zonneplan's coordinator (#181106) --- .../components/zonneplan/coordinator.py | 53 +++++++++++-------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/zonneplan/coordinator.py b/homeassistant/components/zonneplan/coordinator.py index c0a3e6801caa05..fbc439ab6fdb62 100644 --- a/homeassistant/components/zonneplan/coordinator.py +++ b/homeassistant/components/zonneplan/coordinator.py @@ -1,9 +1,11 @@ """Coordinator for Zonneplan.""" +import asyncio +from collections.abc import Coroutine from dataclasses import dataclass from datetime import timedelta import logging -from typing import TYPE_CHECKING, override +from typing import TYPE_CHECKING, Any, override from pyzonneplan import ( Account, @@ -62,27 +64,34 @@ async def _async_update_data(self) -> ZonneplanData: """Fetch data from the Zonneplan API.""" try: account = await self.zonneplan.async_get_account() - electricity_prices: ConsumerPrices | None = None - gas_prices: ConsumerPrices | None = None - - # Depending per contract, fetch the associated consumer prices - for connection in account.connections: - if ( - "electricity" in connection.market_segment - if connection.market_segment is not None - else False - ): - electricity_prices = await self.zonneplan.async_get_consumer_prices( + price_coroutines: dict[str, Coroutine[Any, Any, ConsumerPrices]] = {} + + if any( + connection.market_segment is not None + and "electricity" in connection.market_segment + for connection in account.connections + ): + price_coroutines["electricity"] = ( + self.zonneplan.async_get_consumer_prices( PriceChart.ELECTRICITY_HOURLY ) - if ( - "gas" in connection.market_segment - if connection.market_segment is not None - else False - ): - gas_prices = await self.zonneplan.async_get_consumer_prices( - PriceChart.GAS_DAILY - ) + ) + if any( + connection.market_segment is not None + and "gas" in connection.market_segment + for connection in account.connections + ): + price_coroutines["gas"] = self.zonneplan.async_get_consumer_prices( + PriceChart.GAS_DAILY + ) + + prices = dict( + zip( + price_coroutines, + await asyncio.gather(*price_coroutines.values()), + strict=True, + ) + ) except ZonneplanAuthenticationError as err: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, @@ -111,6 +120,6 @@ async def _async_update_data(self) -> ZonneplanData: return ZonneplanData( account=account, - electricity_prices=electricity_prices, - gas_prices=gas_prices, + electricity_prices=prices.get("electricity"), + gas_prices=prices.get("gas"), ) From fc78d26a747b377d9cb0fd82386b244635758ffb Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 10 Sep 2026 20:07:23 +0200 Subject: [PATCH 12/21] Add outdoor temperature to MELCloud Home (#181216) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/melcloud_home/__init__.py | 9 +- .../components/melcloud_home/coordinator.py | 69 ++++++++++++--- .../components/melcloud_home/diagnostics.py | 5 +- .../components/melcloud_home/icons.json | 3 + .../components/melcloud_home/sensor.py | 85 +++++++++++++------ .../components/melcloud_home/strings.json | 3 + tests/components/melcloud_home/conftest.py | 1 + .../melcloud_home/fixtures/context.json | 2 +- .../snapshots/test_diagnostics.ambr | 13 ++- .../melcloud_home/snapshots/test_sensor.ambr | 58 +++++++++++++ tests/components/melcloud_home/test_init.py | 70 +++++++++++---- 11 files changed, 250 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/melcloud_home/__init__.py b/homeassistant/components/melcloud_home/__init__.py index 99bc92ad46ed78..a8212afb31cd0c 100644 --- a/homeassistant/components/melcloud_home/__init__.py +++ b/homeassistant/components/melcloud_home/__init__.py @@ -9,8 +9,8 @@ from .coordinator import ( MelCloudHomeConfigEntry, MelCloudHomeCoordinator, - MelCloudHomeEnergyCoordinator, MelCloudHomeRuntimeData, + MelCloudHomeTelemetryCoordinator, ) PLATFORMS: list[Platform] = [ @@ -35,14 +35,15 @@ async def async_setup_entry( client = MELCloudHome(auth=auth, session=session) coordinator = MelCloudHomeCoordinator(hass, entry, client) - energy_coordinator = MelCloudHomeEnergyCoordinator(hass, entry, client) + telemetry_coordinator = MelCloudHomeTelemetryCoordinator(hass, entry, client) # It has to be this order, to avoid a race condition await coordinator.async_config_entry_first_refresh() - await energy_coordinator.async_config_entry_first_refresh() + await telemetry_coordinator.async_config_entry_first_refresh() entry.runtime_data = MelCloudHomeRuntimeData( - coordinator=coordinator, energy_coordinator=energy_coordinator + coordinator=coordinator, + telemetry_coordinator=telemetry_coordinator, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/melcloud_home/coordinator.py b/homeassistant/components/melcloud_home/coordinator.py index a415327eb8c87c..4e2f85bf4606fa 100644 --- a/homeassistant/components/melcloud_home/coordinator.py +++ b/homeassistant/components/melcloud_home/coordinator.py @@ -26,7 +26,7 @@ _LOGGER = logging.getLogger(__name__) UPDATE_INTERVAL = timedelta(seconds=60) -ENERGY_UPDATE_INTERVAL = timedelta(minutes=15) +TELEMETRY_UPDATE_INTERVAL = timedelta(minutes=15) @dataclass(kw_only=True, frozen=True) @@ -34,7 +34,7 @@ class MelCloudHomeRuntimeData: """Runtime data for the MELCloud Home config entry.""" coordinator: MelCloudHomeCoordinator - energy_coordinator: MelCloudHomeEnergyCoordinator + telemetry_coordinator: MelCloudHomeTelemetryCoordinator type MelCloudHomeConfigEntry = ConfigEntry[MelCloudHomeRuntimeData] @@ -150,8 +150,18 @@ def _async_refresh_finished(self) -> None: self._notify_new_units(self.data) -class MelCloudHomeEnergyCoordinator(DataUpdateCoordinator[dict[str, float | None]]): - """Coordinator to manage fetching MELCloud Home energy telemetry.""" +@dataclass(kw_only=True, frozen=True) +class MelCloudHomeTelemetryData: + """Telemetry data fetched periodically for MELCloud Home units.""" + + energy: dict[str, float | None] + outdoor_temperature: dict[str, float | None] + + +class MelCloudHomeTelemetryCoordinator( + DataUpdateCoordinator[MelCloudHomeTelemetryData] +): + """Coordinator to manage fetching MELCloud Home energy and outdoor temperature telemetry.""" config_entry: MelCloudHomeConfigEntry @@ -166,8 +176,8 @@ def __init__( hass, _LOGGER, config_entry=entry, - name=f"{DOMAIN}_energy", - update_interval=ENERGY_UPDATE_INTERVAL, + name=f"{DOMAIN}_telemetry", + update_interval=TELEMETRY_UPDATE_INTERVAL, ) self.client = client @@ -188,9 +198,21 @@ async def _async_get_energy( return None return sum(float(e.value) for e in energy) + async def _async_get_outdoor_temperature(self, unit_id: str) -> float | None: + """Fetch outdoor temperature for a unit without failing the whole update.""" + try: + return await self.client.get_outdoor_temperature(unit_id) + except ( + MelCloudHomeAuthenticationError, + MelCloudHomeConnectionError, + MelCloudHomeTimeoutError, + ): + _LOGGER.warning("Failed to fetch outdoor temperature for %s", unit_id) + return None + @override - async def _async_update_data(self) -> dict[str, float | None]: - """Fetch energy telemetry for all units with an energy meter.""" + async def _async_update_data(self) -> MelCloudHomeTelemetryData: + """Fetch energy and outdoor temperature telemetry for all supported units.""" try: data = await self.client.get_context() except MelCloudHomeAuthenticationError as err: @@ -215,6 +237,9 @@ async def _async_update_data(self) -> dict[str, float | None]: now = utcnow() energy_coroutines: dict[str, Coroutine[None, None, float | None]] = {} + outdoor_temperature_coroutine: dict[ + str, Coroutine[None, None, float | None] + ] = {} for building in data.buildings: for ata_unit in building.air_to_air_units: if ( @@ -224,6 +249,14 @@ async def _async_update_data(self) -> dict[str, float | None]: energy_coroutines[ata_unit.id] = self._async_get_energy( ata_unit.id, start_of_month, now ) + if ( + ata_unit.capabilities + and ata_unit.capabilities.has_outdoor_temperature_sensor + ): + outdoor_temperature_coroutine[ata_unit.id] = ( + self._async_get_outdoor_temperature(ata_unit.id) + ) + for atw_unit in building.air_to_water_units: if ( atw_unit.capabilities @@ -233,10 +266,18 @@ async def _async_update_data(self) -> dict[str, float | None]: atw_unit.id, start_of_month, now ) - return dict( - zip( - energy_coroutines, - await asyncio.gather(*energy_coroutines.values()), - strict=True, - ) + energy_values, outdoor_temperature_values = await asyncio.gather( + asyncio.gather(*energy_coroutines.values()), + asyncio.gather(*outdoor_temperature_coroutine.values()), + ) + + return MelCloudHomeTelemetryData( + energy=dict(zip(energy_coroutines, energy_values, strict=True)), + outdoor_temperature=dict( + zip( + outdoor_temperature_coroutine, + outdoor_temperature_values, + strict=True, + ) + ), ) diff --git a/homeassistant/components/melcloud_home/diagnostics.py b/homeassistant/components/melcloud_home/diagnostics.py index 042a36ecfeef53..3d288f57f5b1dd 100644 --- a/homeassistant/components/melcloud_home/diagnostics.py +++ b/homeassistant/components/melcloud_home/diagnostics.py @@ -1,5 +1,6 @@ """Diagnostics for MELCloud Home integration.""" +from dataclasses import asdict from typing import Any from homeassistant.components.diagnostics import async_redact_data @@ -30,7 +31,7 @@ async def async_get_config_entry_diagnostics( config_entry.runtime_data.coordinator.data.model_dump(mode="json"), TO_REDACT, ), - "energy_coordinator": async_redact_data( - config_entry.runtime_data.energy_coordinator.data, TO_REDACT + "telemetry_coordinator": async_redact_data( + asdict(config_entry.runtime_data.telemetry_coordinator.data), TO_REDACT ), } diff --git a/homeassistant/components/melcloud_home/icons.json b/homeassistant/components/melcloud_home/icons.json index dc98ac2263b0fa..96704ec4f40b39 100644 --- a/homeassistant/components/melcloud_home/icons.json +++ b/homeassistant/components/melcloud_home/icons.json @@ -44,6 +44,9 @@ } }, "sensor": { + "outdoor_temperature": { + "default": "mdi:thermometer" + }, "room_temperature": { "default": "mdi:home-thermometer" }, diff --git a/homeassistant/components/melcloud_home/sensor.py b/homeassistant/components/melcloud_home/sensor.py index 8573eeea344bb4..91ea29c20faf11 100644 --- a/homeassistant/components/melcloud_home/sensor.py +++ b/homeassistant/components/melcloud_home/sensor.py @@ -29,7 +29,7 @@ from .coordinator import ( MelCloudHomeConfigEntry, MelCloudHomeCoordinator, - MelCloudHomeEnergyCoordinator, + MelCloudHomeTelemetryCoordinator, ) from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity @@ -44,6 +44,15 @@ suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ) +OUTDOOR_TEMPERATURE_DESCRIPTION = SensorEntityDescription( + key="outdoor_temperature", + translation_key="outdoor_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=1, +) + @dataclass(frozen=True, kw_only=True) class MelCloudHomeSensorEntityDescription[_UnitT: ATAUnit | ATWUnit]( @@ -128,7 +137,7 @@ async def async_setup_entry( ) -> None: """Set up MELCloud Home sensors.""" coordinator = entry.runtime_data.coordinator - energy_coordinator = entry.runtime_data.energy_coordinator + telemetry_coordinator = entry.runtime_data.telemetry_coordinator async_setup_unit_entities( coordinator, @@ -141,11 +150,15 @@ async def async_setup_entry( if entity_description.exists_fn(unit) ), ( - ATAEnergySensor(coordinator, energy_coordinator, unit) + ATAEnergySensor(coordinator, telemetry_coordinator, unit) + for unit in units + if unit.capabilities and unit.capabilities.has_energy_consumed_meter + ), + ( + ATAOutdoorTemperatureSensor(coordinator, telemetry_coordinator, unit) for unit in units - if bool( - unit.capabilities and unit.capabilities.has_energy_consumed_meter - ) + if unit.capabilities + and unit.capabilities.has_outdoor_temperature_sensor ), ), lambda units: chain( @@ -156,11 +169,9 @@ async def async_setup_entry( if entity_description.exists_fn(unit) ), ( - ATWEnergySensor(coordinator, energy_coordinator, unit) + ATWEnergySensor(coordinator, telemetry_coordinator, unit) for unit in units - if bool( - unit.capabilities and unit.capabilities.has_energy_consumed_meter - ) + if unit.capabilities and unit.capabilities.has_energy_consumed_meter ), ), ) @@ -212,41 +223,47 @@ def native_value(self) -> StateType: return self.entity_description.value_fn(self.unit, self.coordinator) -class ATAEnergySensor(MelCloudHomeATAUnitEntity, SensorEntity): - """Representation of a MELCloud Home ATA energy sensor.""" - - entity_description = ENERGY_CONSUMED_DESCRIPTION +class MelCloudHomeATATelemetrySensor(MelCloudHomeATAUnitEntity, SensorEntity): + """Base class for MELCloud Home ATA sensors backed by the telemetry coordinator.""" def __init__( self, coordinator: MelCloudHomeCoordinator, - energy_coordinator: MelCloudHomeEnergyCoordinator, + telemetry_coordinator: MelCloudHomeTelemetryCoordinator, unit: ATAUnit, ) -> None: """Initialize the entity.""" super().__init__(coordinator, unit) - self._energy_coordinator = energy_coordinator - self._attr_unique_id = f"{unit.id}_{ENERGY_CONSUMED_DESCRIPTION.key}" + self._telemetry_coordinator = telemetry_coordinator + self._attr_unique_id = f"{unit.id}_{self.entity_description.key}" @override async def async_added_to_hass(self) -> None: - """Also react to updates from the energy coordinator.""" + """Also react to updates from the telemetry coordinator.""" await super().async_added_to_hass() self.async_on_remove( - self._energy_coordinator.async_add_listener(self._handle_coordinator_update) + self._telemetry_coordinator.async_add_listener( + self._handle_coordinator_update + ) ) @property @override def available(self) -> bool: """Return if the entity is available.""" - return super().available and self._energy_coordinator.last_update_success + return super().available and self._telemetry_coordinator.last_update_success + + +class ATAEnergySensor(MelCloudHomeATATelemetrySensor): + """Representation of a MELCloud Home ATA energy sensor.""" + + entity_description = ENERGY_CONSUMED_DESCRIPTION @property @override def native_value(self) -> StateType: """Return the state of the sensor.""" - return self._energy_coordinator.data.get(self._unit_id) + return self._telemetry_coordinator.data.energy.get(self._unit_id) @property @override @@ -255,6 +272,18 @@ def last_reset(self) -> datetime: return utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0) +class ATAOutdoorTemperatureSensor(MelCloudHomeATATelemetrySensor): + """Representation of a MELCloud Home ATA outdoor temperature sensor.""" + + entity_description = OUTDOOR_TEMPERATURE_DESCRIPTION + + @property + @override + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self._telemetry_coordinator.data.outdoor_temperature.get(self._unit_id) + + class ATWEnergySensor(MelCloudHomeATWUnitEntity, SensorEntity): """Representation of a MELCloud Home ATW energy sensor.""" @@ -263,33 +292,35 @@ class ATWEnergySensor(MelCloudHomeATWUnitEntity, SensorEntity): def __init__( self, coordinator: MelCloudHomeCoordinator, - energy_coordinator: MelCloudHomeEnergyCoordinator, + telemetry_coordinator: MelCloudHomeTelemetryCoordinator, unit: ATWUnit, ) -> None: """Initialize the entity.""" super().__init__(coordinator, unit) - self._energy_coordinator = energy_coordinator + self._telemetry_coordinator = telemetry_coordinator self._attr_unique_id = f"{unit.id}_{ENERGY_CONSUMED_DESCRIPTION.key}" @override async def async_added_to_hass(self) -> None: - """Also react to updates from the energy coordinator.""" + """Also react to updates from the telemetry coordinator.""" await super().async_added_to_hass() self.async_on_remove( - self._energy_coordinator.async_add_listener(self._handle_coordinator_update) + self._telemetry_coordinator.async_add_listener( + self._handle_coordinator_update + ) ) @property @override def available(self) -> bool: """Return if the entity is available.""" - return super().available and self._energy_coordinator.last_update_success + return super().available and self._telemetry_coordinator.last_update_success @property @override def native_value(self) -> StateType: """Return the state of the sensor.""" - return self._energy_coordinator.data.get(self._unit_id) + return self._telemetry_coordinator.data.energy.get(self._unit_id) @property @override diff --git a/homeassistant/components/melcloud_home/strings.json b/homeassistant/components/melcloud_home/strings.json index 4455082d2375b7..e964ae34019cfb 100644 --- a/homeassistant/components/melcloud_home/strings.json +++ b/homeassistant/components/melcloud_home/strings.json @@ -124,6 +124,9 @@ "energy_consumed": { "name": "Energy consumed (monthly)" }, + "outdoor_temperature": { + "name": "Outdoor temperature" + }, "room_temperature": { "name": "Room temperature" }, diff --git a/tests/components/melcloud_home/conftest.py b/tests/components/melcloud_home/conftest.py index 1eb04056809e3a..ac781bd4212958 100644 --- a/tests/components/melcloud_home/conftest.py +++ b/tests/components/melcloud_home/conftest.py @@ -47,6 +47,7 @@ def mock_melcloud_client() -> Generator[AsyncMock]: TelemetryValue.model_validate(value) for value in load_json_array_fixture("energy.json", DOMAIN) ] + client.get_outdoor_temperature.return_value = 19.5 with ( patch( diff --git a/tests/components/melcloud_home/fixtures/context.json b/tests/components/melcloud_home/fixtures/context.json index db95462950fb1a..e5209c5f044cdc 100644 --- a/tests/components/melcloud_home/fixtures/context.json +++ b/tests/components/melcloud_home/fixtures/context.json @@ -43,7 +43,7 @@ "hasDemandSideControl": false, "hasHalfDegreeIncrements": true, "supportsWideVane": false, - "hasOutdoorTemperatureSensor": false, + "hasOutdoorTemperatureSensor": true, "hasVaneVertical": true, "hasVaneHorizontal": true, "hasStandbyMode": false diff --git a/tests/components/melcloud_home/snapshots/test_diagnostics.ambr b/tests/components/melcloud_home/snapshots/test_diagnostics.ambr index 808999cdc77621..6308ca8c60a298 100644 --- a/tests/components/melcloud_home/snapshots/test_diagnostics.ambr +++ b/tests/components/melcloud_home/snapshots/test_diagnostics.ambr @@ -36,7 +36,7 @@ 'has_energy_consumed_meter': True, 'has_fan_operation_mode': None, 'has_half_degree_increments': True, - 'has_outdoor_temperature_sensor': False, + 'has_outdoor_temperature_sensor': True, 'has_standby_mode': False, 'has_vane_horizontal': True, 'has_vane_vertical': True, @@ -269,9 +269,14 @@ 'number_of_guest_devices_allowed': 10, 'number_of_guests_allowed_per_unit': 5, }), - 'energy_coordinator': dict({ - 'ata-unit-uuid-1': 450.5, - 'atw-unit-uuid-1': 450.5, + 'telemetry_coordinator': dict({ + 'energy': dict({ + 'ata-unit-uuid-1': 450.5, + 'atw-unit-uuid-1': 450.5, + }), + 'outdoor_temperature': dict({ + 'ata-unit-uuid-1': 19.5, + }), }), }) # --- diff --git a/tests/components/melcloud_home/snapshots/test_sensor.ambr b/tests/components/melcloud_home/snapshots/test_sensor.ambr index 755967d4fe2064..31547eb2d23d1f 100644 --- a/tests/components/melcloud_home/snapshots/test_sensor.ambr +++ b/tests/components/melcloud_home/snapshots/test_sensor.ambr @@ -352,6 +352,64 @@ 'state': '0.4505', }) # --- +# name: test_all_entities[sensor.living_room_ac_outdoor_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_ac_outdoor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Outdoor temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor temperature', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'outdoor_temperature', + 'unique_id': 'ata-unit-uuid-1_outdoor_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.living_room_ac_outdoor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living Room AC Outdoor temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.living_room_ac_outdoor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.5', + }) +# --- # name: test_all_entities[sensor.living_room_ac_room_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/melcloud_home/test_init.py b/tests/components/melcloud_home/test_init.py index 5049dae794e00a..0e968a386e03e2 100644 --- a/tests/components/melcloud_home/test_init.py +++ b/tests/components/melcloud_home/test_init.py @@ -13,7 +13,7 @@ from homeassistant.components.melcloud_home.const import DOMAIN from homeassistant.components.melcloud_home.coordinator import ( - ENERGY_UPDATE_INTERVAL, + TELEMETRY_UPDATE_INTERVAL, UPDATE_INTERVAL, ) from homeassistant.config_entries import ConfigEntryState @@ -216,27 +216,27 @@ async def test_energy_update_cycle_fails( ) -> None: """Test that a failing energy fetch clears the value without unloading the entry.""" await setup_integration(hass, mock_config_entry) - energy_coordinator = mock_config_entry.runtime_data.energy_coordinator + telemetry_coordinator = mock_config_entry.runtime_data.telemetry_coordinator - assert energy_coordinator.data["ata-unit-uuid-1"] is not None - assert energy_coordinator.data["atw-unit-uuid-1"] is not None + assert telemetry_coordinator.data.energy["ata-unit-uuid-1"] is not None + assert telemetry_coordinator.data.energy["atw-unit-uuid-1"] is not None mock_melcloud_client.get_energy_telemetry.side_effect = exception - freezer.tick(ENERGY_UPDATE_INTERVAL) + freezer.tick(TELEMETRY_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - assert energy_coordinator.data["ata-unit-uuid-1"] is None - assert energy_coordinator.data["atw-unit-uuid-1"] is None + assert telemetry_coordinator.data.energy["ata-unit-uuid-1"] is None + assert telemetry_coordinator.data.energy["atw-unit-uuid-1"] is None # Demonstrate a recovery mock_melcloud_client.get_energy_telemetry.side_effect = None - freezer.tick(ENERGY_UPDATE_INTERVAL) + freezer.tick(TELEMETRY_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - assert energy_coordinator.data["ata-unit-uuid-1"] is not None - assert energy_coordinator.data["atw-unit-uuid-1"] is not None + assert telemetry_coordinator.data.energy["ata-unit-uuid-1"] is not None + assert telemetry_coordinator.data.energy["atw-unit-uuid-1"] is not None @pytest.mark.parametrize( @@ -258,11 +258,13 @@ async def test_energy_telemetry_fetch_failure( await setup_integration(hass, mock_config_entry) mock_melcloud_client.get_energy_telemetry.side_effect = exception - freezer.tick(ENERGY_UPDATE_INTERVAL) + freezer.tick(TELEMETRY_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - assert mock_config_entry.runtime_data.energy_coordinator.last_update_success is True + assert ( + mock_config_entry.runtime_data.telemetry_coordinator.last_update_success is True + ) assert mock_config_entry.runtime_data.coordinator.last_update_success is True @@ -274,19 +276,19 @@ async def test_energy_telemetry_fetch_failure( pytest.param(MelCloudHomeTimeoutError("timeout"), id="timeout"), ], ) -async def test_energy_coordinator_context_fetch_failure( +async def test_telemetry_coordinator_context_fetch_failure( hass: HomeAssistant, mock_melcloud_client: AsyncMock, mock_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, exception: Exception, ) -> None: - """Test that a failing energy coordinator refresh doesn't affect the main coordinator.""" + """Test that a failing telemetry coordinator refresh doesn't affect the main coordinator.""" await setup_integration(hass, mock_config_entry) # Split the margin so the main coordinator's rescheduled refresh doesn't land - # exactly on the energy coordinator's, which would make both fail below. - freezer.tick(ENERGY_UPDATE_INTERVAL - UPDATE_INTERVAL / 2) + # exactly on the telemetry coordinator's, which would make both fail below. + freezer.tick(TELEMETRY_UPDATE_INTERVAL - UPDATE_INTERVAL / 2) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -308,3 +310,39 @@ async def test_energy_coordinator_context_fetch_failure( ) ) assert room_temperature_sensor.state != STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + "exception", + [ + pytest.param(MelCloudHomeAuthenticationError("bad creds"), id="auth"), + pytest.param(MelCloudHomeConnectionError("cannot connect"), id="connection"), + pytest.param(MelCloudHomeTimeoutError("timeout"), id="timeout"), + ], +) +async def test_outdoor_temperature_update_cycle_fails( + hass: HomeAssistant, + mock_melcloud_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + exception: Exception, +) -> None: + """Test that a failing outdoor temperature fetch clears the value without unloading the entry.""" + await setup_integration(hass, mock_config_entry) + telemetry_coordinator = mock_config_entry.runtime_data.telemetry_coordinator + + assert telemetry_coordinator.data.outdoor_temperature["ata-unit-uuid-1"] is not None + + mock_melcloud_client.get_outdoor_temperature.side_effect = exception + freezer.tick(TELEMETRY_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert telemetry_coordinator.data.outdoor_temperature["ata-unit-uuid-1"] is None + + mock_melcloud_client.get_outdoor_temperature.side_effect = None + freezer.tick(TELEMETRY_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert telemetry_coordinator.data.outdoor_temperature["ata-unit-uuid-1"] is not None From 3ea011d36bd1a40879b59715c5b1e8777ff96996 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 10 Sep 2026 20:10:13 +0200 Subject: [PATCH 13/21] Improve error handling in Airly integration (#181718) --- homeassistant/components/airly/config_flow.py | 23 ++++- homeassistant/components/airly/const.py | 1 + homeassistant/components/airly/coordinator.py | 23 ++--- homeassistant/components/airly/strings.json | 2 + tests/components/airly/test_config_flow.py | 92 ++++++++++++++++++- tests/components/airly/test_sensor.py | 16 +++- 6 files changed, 136 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/airly/config_flow.py b/homeassistant/components/airly/config_flow.py index 79c79fd81a8db2..510e64f34a50cf 100644 --- a/homeassistant/components/airly/config_flow.py +++ b/homeassistant/components/airly/config_flow.py @@ -2,9 +2,10 @@ from asyncio import timeout from http import HTTPStatus +import logging from typing import Any, override -from aiohttp import ClientSession +from aiohttp import ClientConnectorError, ClientSession from airly import Airly from airly.exceptions import AirlyError import voluptuous as vol @@ -14,11 +15,18 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_USE_NEAREST, DEFAULT_NAME, DOMAIN, NO_AIRLY_SENSORS +from .const import ( + CONF_USE_NEAREST, + DEFAULT_NAME, + DEFAULT_TIMEOUT, + DOMAIN, + NO_AIRLY_SENSORS, +) DESCRIPTION_PLACEHOLDERS = { "developer_registration_url": "https://developer.airly.eu/register", } +_LOGGER = logging.getLogger(__name__) class AirlyFlowHandler(ConfigFlow, domain=DOMAIN): @@ -59,8 +67,15 @@ async def async_step_user( except AirlyError as err: if err.status_code == HTTPStatus.UNAUTHORIZED: errors["base"] = "invalid_api_key" - if err.status_code == HTTPStatus.NOT_FOUND: + elif err.status_code == HTTPStatus.NOT_FOUND: errors["base"] = "wrong_location" + else: + errors["base"] = "unknown" + except ClientConnectorError, TimeoutError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" else: if not location_point_valid: if not location_nearest_valid: @@ -106,7 +121,7 @@ async def check_location( measurements = airly.create_measurements_session_point( latitude=latitude, longitude=longitude ) - async with timeout(10): + async with timeout(DEFAULT_TIMEOUT): await measurements.update() current = measurements.current diff --git a/homeassistant/components/airly/const.py b/homeassistant/components/airly/const.py index 9655503a085f0e..e95484a43b5779 100644 --- a/homeassistant/components/airly/const.py +++ b/homeassistant/components/airly/const.py @@ -37,3 +37,4 @@ URL = "https://airly.org/map/#{latitude},{longitude}" DEFAULT_NAME: Final = "Airly" +DEFAULT_TIMEOUT = 10 diff --git a/homeassistant/components/airly/coordinator.py b/homeassistant/components/airly/coordinator.py index aebae37e91b115..2447096e7a4125 100644 --- a/homeassistant/components/airly/coordinator.py +++ b/homeassistant/components/airly/coordinator.py @@ -21,6 +21,7 @@ ATTR_API_CAQI, ATTR_API_CAQI_DESCRIPTION, ATTR_API_CAQI_LEVEL, + DEFAULT_TIMEOUT, DOMAIN, MAX_UPDATE_INTERVAL, MIN_UPDATE_INTERVAL, @@ -103,18 +104,18 @@ async def _async_update_data(self) -> dict[str, str | float | int]: measurements = self.airly.create_measurements_session_point( self.latitude, self.longitude ) - async with timeout(20): - try: + try: + async with timeout(DEFAULT_TIMEOUT): await measurements.update() - except (AirlyError, ClientConnectorError) as error: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="update_error", - translation_placeholders={ - "entry": self.config_entry.title, - "error": repr(error), - }, - ) from error + except (AirlyError, ClientConnectorError, TimeoutError) as error: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={ + "entry": self.config_entry.title, + "error": repr(error), + }, + ) from error _LOGGER.debug( "Requests remaining: %s/%s", diff --git a/homeassistant/components/airly/strings.json b/homeassistant/components/airly/strings.json index 0a98aa335764f5..2348a537cddf03 100644 --- a/homeassistant/components/airly/strings.json +++ b/homeassistant/components/airly/strings.json @@ -5,7 +5,9 @@ "wrong_location": "[%key:component::airly::config::error::wrong_location%]" }, "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "unknown": "[%key:common::config_flow::error::unknown%]", "wrong_location": "No Airly measuring stations in this area." }, "step": { diff --git a/tests/components/airly/test_config_flow.py b/tests/components/airly/test_config_flow.py index 756858ca168d67..d9ba6418401a1a 100644 --- a/tests/components/airly/test_config_flow.py +++ b/tests/components/airly/test_config_flow.py @@ -2,8 +2,9 @@ from collections.abc import Generator from http import HTTPStatus -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock, patch +from aiohttp import ClientConnectorError from airly.exceptions import AirlyError import pytest @@ -15,7 +16,7 @@ from . import API_NEAREST_URL, API_POINT_URL -from tests.common import MockConfigEntry, async_load_fixture, patch +from tests.common import MockConfigEntry, async_load_fixture from tests.test_util.aiohttp import AiohttpClientMocker CONFIG = { @@ -228,3 +229,90 @@ async def test_create_entry_with_nearest_method( assert result["data"][CONF_LONGITUDE] == CONFIG[CONF_LONGITUDE] assert result["data"][CONF_API_KEY] == CONFIG[CONF_API_KEY] assert result["data"][CONF_USE_NEAREST] is True + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (TimeoutError(), "cannot_connect"), + (ClientConnectorError(Mock(), OSError("test")), "cannot_connect"), + ], +) +async def test_cannot_connect( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + exception: Exception, + error: str, +) -> None: + """Test that cannot_connect error is shown when connection fails.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with patch("airly.measurements.MeasurementsSession.update", side_effect=exception): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONFIG + ) + + assert result["errors"] == {"base": error} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + aioclient_mock.clear_requests() + aioclient_mock.get( + API_POINT_URL, text=await async_load_fixture(hass, "valid_station.json", DOMAIN) + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONFIG + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (Exception("unexpected"), "unknown"), + ( + AirlyError(HTTPStatus.INTERNAL_SERVER_ERROR, {"message": "Server error"}), + "unknown", + ), + ], +) +async def test_unknown_error( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + exception: Exception, + error: str, +) -> None: + """Test that unknown error is shown for unexpected exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with patch("airly.measurements.MeasurementsSession.update", side_effect=exception): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONFIG + ) + + assert result["errors"] == {"base": error} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + aioclient_mock.clear_requests() + aioclient_mock.get( + API_POINT_URL, text=await async_load_fixture(hass, "valid_station.json", DOMAIN) + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONFIG + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/airly/test_sensor.py b/tests/components/airly/test_sensor.py index bd9d4cbe7c238e..1e723e40648c37 100644 --- a/tests/components/airly/test_sensor.py +++ b/tests/components/airly/test_sensor.py @@ -5,6 +5,7 @@ from unittest.mock import patch from airly.exceptions import AirlyError +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.airly.const import DOMAIN @@ -43,8 +44,17 @@ async def test_sensor( assert state == snapshot(name=f"{entity_entry.entity_id}-state") +@pytest.mark.parametrize( + "exception", + [ + AirlyError(HTTPStatus.NOT_FOUND, {"message": "Not found"}), + TimeoutError(), + ], +) async def test_availability( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + exception: Exception, ) -> None: """Ensure that we mark the entities unavailable correctly. @@ -58,9 +68,7 @@ async def test_availability( assert state.state == "68.35" aioclient_mock.clear_requests() - aioclient_mock.get( - API_POINT_URL, exc=AirlyError(HTTPStatus.NOT_FOUND, {"message": "Not found"}) - ) + aioclient_mock.get(API_POINT_URL, exc=exception) future = utcnow() + timedelta(minutes=60) async_fire_time_changed(hass, future) await hass.async_block_till_done() From ed25ae36420848c42bcce74aaff3166bac11dffa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Mind=C3=AAllo=20de=20Andrade?= Date: Thu, 10 Sep 2026 15:19:49 -0300 Subject: [PATCH 14/21] Add Midea Water Heater platform (#180693) --- homeassistant/components/midea/__init__.py | 1 + homeassistant/components/midea/strings.json | 8 + .../components/midea/water_heater.py | 430 +++++++++++++++ .../midea/snapshots/test_water_heater.ambr | 374 +++++++++++++ tests/components/midea/test_water_heater.py | 515 ++++++++++++++++++ 5 files changed, 1328 insertions(+) create mode 100644 homeassistant/components/midea/water_heater.py create mode 100644 tests/components/midea/snapshots/test_water_heater.ambr create mode 100644 tests/components/midea/test_water_heater.py diff --git a/homeassistant/components/midea/__init__.py b/homeassistant/components/midea/__init__.py index be4db2e9a387a8..7fea47831bd4fe 100644 --- a/homeassistant/components/midea/__init__.py +++ b/homeassistant/components/midea/__init__.py @@ -40,6 +40,7 @@ Platform.SENSOR, Platform.SWITCH, Platform.TIME, + Platform.WATER_HEATER, ] diff --git a/homeassistant/components/midea/strings.json b/homeassistant/components/midea/strings.json index 356573180aeb75..9a2f0ccb8c2683 100644 --- a/homeassistant/components/midea/strings.json +++ b/homeassistant/components/midea/strings.json @@ -721,6 +721,14 @@ "timing_regeneration": { "name": "Regeneration time" } + }, + "water_heater": { + "water_heater_bathing": { + "name": "Bathing water heater" + }, + "water_heater_heating": { + "name": "Heating water heater" + } } }, "exceptions": { diff --git a/homeassistant/components/midea/water_heater.py b/homeassistant/components/midea/water_heater.py new file mode 100644 index 00000000000000..9be9a3b92135d7 --- /dev/null +++ b/homeassistant/components/midea/water_heater.py @@ -0,0 +1,430 @@ +"""Water Heater platform for Midea.""" + +from dataclasses import dataclass +from typing import Any, ClassVar, cast, override + +from midealocal.device import DeviceType +from midealocal.devices.c3 import DeviceAttributes as C3Attributes, MideaC3Device +from midealocal.devices.cd import DeviceAttributes as CDAttributes, MideaCDDevice +from midealocal.devices.e2 import DeviceAttributes as E2Attributes, MideaE2Device +from midealocal.devices.e3 import DeviceAttributes as E3Attributes, MideaE3Device +from midealocal.devices.e6 import DeviceAttributes as E6Attributes, MideaE6Device + +from homeassistant.components.water_heater import ( + WaterHeaterEntity, + WaterHeaterEntityDescription, + WaterHeaterEntityFeature, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + PRECISION_HALVES, + PRECISION_WHOLE, + STATE_OFF, + STATE_ON, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import MideaConfigEntry, MideaEntity, midea_api_call + +PARALLEL_UPDATES = 0 + + +@dataclass(kw_only=True, frozen=True) +class MideaWaterHeaterEntityDescription(WaterHeaterEntityDescription): + """Description for a Midea water heater entity.""" + + models: list[DeviceType] + zone: int = 0 + + +WATER_HEATERS: list[MideaWaterHeaterEntityDescription] = [ + MideaWaterHeaterEntityDescription( + key="water_heater", + translation_key="water_heater", + models=[ + DeviceType.C3, + DeviceType.CD, + DeviceType.E2, + DeviceType.E3, + ], + ), + MideaWaterHeaterEntityDescription( + key="water_heater_heating", + translation_key="water_heater_heating", + models=[ + DeviceType.E6, + ], + ), + MideaWaterHeaterEntityDescription( + key="water_heater_bathing", + translation_key="water_heater_bathing", + models=[ + DeviceType.E6, + ], + zone=1, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MideaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up water heater entries.""" + device = config_entry.runtime_data + + entities: list[MideaWaterHeater] = [] + for description in WATER_HEATERS: + if device.device_type not in description.models: + continue + if device.device_type == DeviceType.E2: + entities.append( + MideaE2WaterHeater(cast("MideaE2Device", device), description) + ) + elif device.device_type == DeviceType.E3: + entities.append( + MideaE3WaterHeater(cast("MideaE3Device", device), description) + ) + elif device.device_type == DeviceType.E6: + entities.append( + MideaE6WaterHeater(cast("MideaE6Device", device), description) + ) + elif device.device_type == DeviceType.C3: + entities.append( + MideaC3WaterHeater(cast("MideaC3Device", device), description) + ) + elif device.device_type == DeviceType.CD: + entities.append( + MideaCDWaterHeater(cast("MideaCDDevice", device), description) + ) + + async_add_entities(entities) + + +type MideaWaterHeaterDevice = ( + MideaE2Device | MideaE3Device | MideaC3Device | MideaE6Device | MideaCDDevice +) + + +class MideaWaterHeater(MideaEntity, WaterHeaterEntity): + """Midea Water Heater Entries Base Class.""" + + _device: MideaWaterHeaterDevice + + def __init__( + self, + device: MideaWaterHeaterDevice, + description: MideaWaterHeaterEntityDescription, + ) -> None: + """Midea Water Heater entity init.""" + super().__init__(device, description) + self._operations: list[str] = [] + self._attr_supported_features = ( + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.ON_OFF + ) + self._attr_precision = float(PRECISION_WHOLE) + self._attr_temperature_unit = UnitOfTemperature.CELSIUS + + @property + @override + def target_temperature_step(self) -> float | None: + """Return the supported target temperature step.""" + return cast("float | None", getattr(self._device, "temperature_step", None)) + + @property + @override + def current_operation(self) -> str | None: + """Midea Water Heater current operation.""" + return STATE_ON if self._device.get_attribute("power") else STATE_OFF + + @property + @override + def current_temperature(self) -> float: + """Midea Water Heater current temperature.""" + return cast("float", self._device.get_attribute("current_temperature")) + + @property + @override + def target_temperature(self) -> float: + """Midea Water Heater target temperature.""" + return cast("float", self._device.get_attribute("target_temperature")) + + @override + def set_temperature(self, **kwargs: Any) -> None: + """Midea Water Heater set temperature.""" + if ATTR_TEMPERATURE not in kwargs: + return # pragma: no cover + with midea_api_call(): + temperature = float(kwargs[ATTR_TEMPERATURE]) + self._device.set_attribute("target_temperature", temperature) + + @override + def turn_on(self, **kwargs: Any) -> None: + """Midea Water Heater turn on.""" + with midea_api_call(): + self._device.set_attribute(attr="power", value=True) + + @override + def turn_off(self, **kwargs: Any) -> None: + """Midea Water Heater turn off.""" + with midea_api_call(): + self._device.set_attribute(attr="power", value=False) + + +class MideaE2WaterHeater(MideaWaterHeater): + """Midea E2 Water Heater Entries.""" + + _device: MideaE2Device + + @property + @override + def min_temp(self) -> float: + """Midea E2 Water Heater min temperature.""" + return cast("float", self._device.get_attribute(E2Attributes.temperature_min)) + + @property + @override + def max_temp(self) -> float: + """Midea E2 Water Heater max temperature.""" + return cast("float", self._device.get_attribute(E2Attributes.temperature_max)) + + +class MideaE3WaterHeater(MideaWaterHeater): + """Midea E3 Water Heater Entries.""" + + _device: MideaE3Device + + @property + @override + def min_temp(self) -> float: + """Midea E3 Water Heater min temperature.""" + return cast("float", self._device.get_attribute(E3Attributes.temperature_min)) + + @property + @override + def max_temp(self) -> float: + """Midea E3 Water Heater max temperature.""" + return cast("float", self._device.get_attribute(E3Attributes.temperature_max)) + + @property + @override + def precision(self) -> float: + """Midea E3 Water Heater precision.""" + return float( + PRECISION_HALVES if self._device.precision_halves else PRECISION_WHOLE, + ) + + +class MideaC3WaterHeater(MideaWaterHeater): + """Midea C3 Water Heater Entries.""" + + _device: MideaC3Device + + @property + @override + def current_operation(self) -> str: + """Midea C3 Water Heater current operation.""" + return ( + STATE_ON + if self._device.get_attribute(C3Attributes.dhw_power) + else STATE_OFF + ) + + @property + @override + def current_temperature(self) -> float: + """Midea C3 Water Heater current temperature.""" + return cast( + "float", + self._device.get_attribute(C3Attributes.tank_actual_temperature), + ) + + @property + @override + def target_temperature(self) -> float: + """Midea C3 Water Heater target temperature.""" + return cast("float", self._device.get_attribute(C3Attributes.dhw_target_temp)) + + @override + def set_temperature(self, **kwargs: Any) -> None: + """Midea C3 Water Heater set temperature.""" + if ATTR_TEMPERATURE not in kwargs: + return # pragma: no cover + with midea_api_call(): + temperature = float(kwargs[ATTR_TEMPERATURE]) + self._device.set_attribute(C3Attributes.dhw_target_temp, temperature) + + @property + @override + def min_temp(self) -> float: + """Midea C3 Water Heater min temperature.""" + return cast("float", self._device.get_attribute(C3Attributes.dhw_temp_min)) + + @property + @override + def max_temp(self) -> float: + """Midea C3 Water Heater max temperature.""" + return cast("float", self._device.get_attribute(C3Attributes.dhw_temp_max)) + + @override + def turn_on(self, **kwargs: Any) -> None: + """Midea C3 Water Heater turn on.""" + with midea_api_call(): + self._device.set_attribute(attr=C3Attributes.dhw_power, value=True) + + @override + def turn_off(self, **kwargs: Any) -> None: + """Midea C3 Water Heater turn off.""" + with midea_api_call(): + self._device.set_attribute(attr=C3Attributes.dhw_power, value=False) + + +class MideaE6WaterHeater(MideaWaterHeater): + """Midea E6 Water Heater Entries.""" + + _device: MideaE6Device + entity_description: MideaWaterHeaterEntityDescription + + _powers: ClassVar[list[E6Attributes]] = [ + E6Attributes.heating_power, + E6Attributes.main_power, + ] + _current_temperatures: ClassVar[list[E6Attributes]] = [ + E6Attributes.heating_leaving_temperature, + E6Attributes.bathing_leaving_temperature, + ] + _target_temperatures: ClassVar[list[E6Attributes]] = [ + E6Attributes.heating_temperature, + E6Attributes.bathing_temperature, + ] + + def __init__( + self, device: MideaE6Device, description: MideaWaterHeaterEntityDescription + ) -> None: + """Midea E6 Water Heater entity init.""" + super().__init__(device, description) + self._power_attr = MideaE6WaterHeater._powers[description.zone] + self._current_temperature_attr = MideaE6WaterHeater._current_temperatures[ + description.zone + ] + self._target_temperature_attr = MideaE6WaterHeater._target_temperatures[ + description.zone + ] + self._attr_supported_features = ( + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.ON_OFF + ) + if description.zone == 0: + self._attr_supported_features |= WaterHeaterEntityFeature.AWAY_MODE + + @property + @override + def current_operation(self) -> str: + """Midea E6 Water Heater current operation.""" + if self.entity_description.zone == 0: + return ( + STATE_ON + if self._device.get_attribute(E6Attributes.main_power) + and self._device.get_attribute(E6Attributes.heating_power) + else STATE_OFF + ) + return ( + STATE_ON + if self._device.get_attribute(E6Attributes.main_power) + else STATE_OFF + ) + + @property + @override + def current_temperature(self) -> float: + """Midea E6 Water Heater current temperature.""" + return cast("float", self._device.get_attribute(self._current_temperature_attr)) + + @property + @override + def target_temperature(self) -> float: + """Midea E6 Water Heater target temperature.""" + return cast("float", self._device.get_attribute(self._target_temperature_attr)) + + @override + def set_temperature(self, **kwargs: Any) -> None: + """Midea E6 Water Heater set temperature.""" + if ATTR_TEMPERATURE not in kwargs: + return # pragma: no cover + with midea_api_call(): + temperature = float(kwargs[ATTR_TEMPERATURE]) + self._device.set_attribute(self._target_temperature_attr, temperature) + + @property + @override + def min_temp(self) -> float: + """Midea E6 Water Heater min temperature.""" + min_temperature = cast( + "list[float]", + self._device.get_attribute(E6Attributes.temperature_min), + ) + return min_temperature[self.entity_description.zone] + + @property + @override + def max_temp(self) -> float: + """Midea E6 Water Heater max temperature.""" + max_temperature = cast( + "list[float]", + self._device.get_attribute(E6Attributes.temperature_max), + ) + return max_temperature[self.entity_description.zone] + + @override + def turn_on(self, **kwargs: Any) -> None: + """Midea E6 Water Heater turn on.""" + with midea_api_call(): + self._device.set_attribute(attr=self._power_attr, value=True) + + @override + def turn_off(self, **kwargs: Any) -> None: + """Midea E6 Water Heater turn off.""" + with midea_api_call(): + self._device.set_attribute(attr=self._power_attr, value=False) + + @property + @override + def is_away_mode_on(self) -> bool: + """Return whether E6 away mode is active.""" + return self._device.get_attribute(E6Attributes.heating_modes) == "out" + + @override + def turn_away_mode_on(self) -> None: + """Midea Water Heater turn away mode on.""" + with midea_api_call(): + self._device.set_attribute(attr=E6Attributes.heating_modes, value="out") + + @override + def turn_away_mode_off(self) -> None: + """Midea Water Heater turn away mode off.""" + with midea_api_call(): + self._device.set_attribute( + attr=E6Attributes.heating_modes, value=self._device.preset_modes[0] + ) + + +class MideaCDWaterHeater(MideaWaterHeater): + """Midea CD Water Heater Entries.""" + + _device: MideaCDDevice + + @property + @override + def min_temp(self) -> float: + """Midea CD Water Heater min temperature.""" + return cast("float", self._device.get_attribute(CDAttributes.min_temperature)) + + @property + @override + def max_temp(self) -> float: + """Midea CD Water Heater max temperature.""" + return cast("float", self._device.get_attribute(CDAttributes.max_temperature)) diff --git a/tests/components/midea/snapshots/test_water_heater.ambr b/tests/components/midea/snapshots/test_water_heater.ambr new file mode 100644 index 00000000000000..372cc771901e5c --- /dev/null +++ b/tests/components/midea/snapshots/test_water_heater.ambr @@ -0,0 +1,374 @@ +# serializer version: 1 +# name: test_midea_water_heater_default_state[c3][water_heater.heat_pump_wi_fi_controller-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 60, + : 20, + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'water_heater', + 'entity_category': None, + 'entity_id': 'water_heater.heat_pump_wi_fi_controller', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'water_heater', + 'unique_id': '12345678_water_heater', + 'unit_of_measurement': None, + }) +# --- +# name: test_midea_water_heater_default_state[c3][water_heater.heat_pump_wi_fi_controller-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : None, + : 'Heat Pump Wi-Fi Controller', + : 60, + : 20, + : , + : None, + : None, + : 1, + : 25, + }), + 'context': , + 'entity_id': 'water_heater.heat_pump_wi_fi_controller', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_midea_water_heater_default_state[cd][water_heater.heat_pump_water_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 65, + : 35, + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'water_heater', + 'entity_category': None, + 'entity_id': 'water_heater.heat_pump_water_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'water_heater', + 'unique_id': '12345678_water_heater', + 'unit_of_measurement': None, + }) +# --- +# name: test_midea_water_heater_default_state[cd][water_heater.heat_pump_water_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 41, + : 'Heat Pump Water Heater', + : 65, + : 35, + : , + : None, + : None, + : 1, + : 40, + }), + 'context': , + 'entity_id': 'water_heater.heat_pump_water_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_midea_water_heater_default_state[e2][water_heater.electric_water_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 75, + : 30, + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'water_heater', + 'entity_category': None, + 'entity_id': 'water_heater.electric_water_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'water_heater', + 'unique_id': '12345678_water_heater', + 'unit_of_measurement': None, + }) +# --- +# name: test_midea_water_heater_default_state[e2][water_heater.electric_water_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 21, + : 'Electric Water Heater', + : 75, + : 30, + : , + : None, + : None, + : 1, + : 40, + }), + 'context': , + 'entity_id': 'water_heater.electric_water_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_midea_water_heater_default_state[e3][water_heater.gas_water_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 65.0, + : 35.0, + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'water_heater', + 'entity_category': None, + 'entity_id': 'water_heater.gas_water_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'water_heater', + 'unique_id': '12345678_water_heater', + 'unit_of_measurement': None, + }) +# --- +# name: test_midea_water_heater_default_state[e3][water_heater.gas_water_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 38.0, + : 'Gas Water Heater', + : 65.0, + : 35.0, + : , + : None, + : None, + : 1, + : 40.0, + }), + 'context': , + 'entity_id': 'water_heater.gas_water_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_midea_water_heater_default_state[e6][water_heater.gas_boiler_bathing_water_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 60, + : 35, + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'water_heater', + 'entity_category': None, + 'entity_id': 'water_heater.gas_boiler_bathing_water_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Bathing water heater', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bathing water heater', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'water_heater_bathing', + 'unique_id': '12345678_water_heater_bathing', + 'unit_of_measurement': None, + }) +# --- +# name: test_midea_water_heater_default_state[e6][water_heater.gas_boiler_bathing_water_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : None, + : 'Gas Boiler Bathing water heater', + : 60, + : 35, + : , + : None, + : None, + : 1, + : 40, + }), + 'context': , + 'entity_id': 'water_heater.gas_boiler_bathing_water_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_midea_water_heater_default_state[e6][water_heater.gas_boiler_heating_water_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 80, + : 30, + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'water_heater', + 'entity_category': None, + 'entity_id': 'water_heater.gas_boiler_heating_water_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Heating water heater', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating water heater', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'water_heater_heating', + 'unique_id': '12345678_water_heater_heating', + 'unit_of_measurement': None, + }) +# --- +# name: test_midea_water_heater_default_state[e6][water_heater.gas_boiler_heating_water_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'off', + : None, + : 'Gas Boiler Heating water heater', + : 80, + : 30, + : , + : None, + : None, + : 1, + : 50, + }), + 'context': , + 'entity_id': 'water_heater.gas_boiler_heating_water_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/midea/test_water_heater.py b/tests/components/midea/test_water_heater.py new file mode 100644 index 00000000000000..06433b2129fcb2 --- /dev/null +++ b/tests/components/midea/test_water_heater.py @@ -0,0 +1,515 @@ +"""Tests for the Midea water heater platform.""" + +from collections.abc import Callable +from unittest.mock import patch + +from midealocal.const import DeviceType +from midealocal.devices.c3 import C3SilentLevel, DeviceAttributes as C3Attributes +from midealocal.devices.cd import DeviceAttributes as CDAttributes +from midealocal.devices.e2 import DeviceAttributes as E2Attributes +from midealocal.devices.e3 import DeviceAttributes as E3Attributes +from midealocal.devices.e6 import DeviceAttributes as E6Attributes +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.water_heater import ( + ATTR_AWAY_MODE, + ATTR_TEMPERATURE, + DOMAIN as WATER_HEATER_DOMAIN, + SERVICE_SET_AWAY_MODE, + SERVICE_SET_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .conftest import DummyDevice, entity_entries +from .const import TEST_DEVICE_ID + +from tests.common import MockConfigEntry, snapshot_platform + + +def _c3_device() -> DummyDevice: + return DummyDevice( + DeviceType.C3, + attributes={ + C3Attributes.zone1_power: False, + C3Attributes.zone2_power: False, + C3Attributes.dhw_power: False, + C3Attributes.zone1_curve: False, + C3Attributes.zone2_curve: False, + C3Attributes.disinfect: False, + C3Attributes.fast_dhw: False, + C3Attributes.zone_temp_type: [False, False], + C3Attributes.zone1_room_temp_mode: False, + C3Attributes.zone2_room_temp_mode: False, + C3Attributes.zone1_water_temp_mode: False, + C3Attributes.zone2_water_temp_mode: False, + C3Attributes.silent_mode: False, + C3Attributes.silent_level: C3SilentLevel.OFF.name.lower(), + C3Attributes.eco_mode: False, + C3Attributes.tbh: False, + C3Attributes.mode: 1, + C3Attributes.mode_auto: 1, + C3Attributes.zone_target_temp: [25.0, 25.0], + C3Attributes.dhw_target_temp: 25.0, + C3Attributes.room_target_temp: 30.0, + C3Attributes.zone_heating_temp_max: [55.0, 55.0], + C3Attributes.zone_heating_temp_min: [25.0, 25.0], + C3Attributes.zone_cooling_temp_max: [25.0, 25.0], + C3Attributes.zone_cooling_temp_min: [5.0, 5.0], + C3Attributes.room_temp_max: 60.0, + C3Attributes.room_temp_min: 34.0, + C3Attributes.dhw_temp_max: 60.0, + C3Attributes.dhw_temp_min: 20.0, + C3Attributes.tank_actual_temperature: None, + C3Attributes.target_temperature: [25.0, 25.0], + C3Attributes.temperature_max: [0.0, 0.0], + C3Attributes.temperature_min: [0.0, 0.0], + C3Attributes.total_energy_consumption: None, + C3Attributes.status_heating: None, + C3Attributes.status_dhw: None, + C3Attributes.status_tbh: None, + C3Attributes.status_ibh: None, + C3Attributes.total_produced_energy: None, + C3Attributes.outdoor_temperature: None, + C3Attributes.temp_tw_in: None, + C3Attributes.temp_tw_out: None, + C3Attributes.instant_power0: None, + C3Attributes.error_code: 0, + }, + ) + + +def _cd_device() -> DummyDevice: + device = DummyDevice( + DeviceType.CD, + attributes={ + CDAttributes.power: True, + CDAttributes.mode: "standard", + CDAttributes.max_temperature: 65.0, + CDAttributes.min_temperature: 35.0, + CDAttributes.target_temperature: 40.0, + CDAttributes.current_temperature: 41.0, + CDAttributes.outdoor_temperature: 25.0, + CDAttributes.condenser_temperature: 23.0, + CDAttributes.compressor_temperature: 30.0, + CDAttributes.compressor_status: None, + CDAttributes.water_level: None, + CDAttributes.fahrenheit: False, + CDAttributes.heat: None, + CDAttributes.dual_heat: None, + CDAttributes.elec_heat: None, + CDAttributes.top_elec_heat: None, + CDAttributes.bottom_elec_heat: None, + CDAttributes.water_pump: None, + CDAttributes.four_way: None, + CDAttributes.back_water: None, + CDAttributes.sterilize: None, + CDAttributes.disinfect: None, + CDAttributes.disinfection_temperature: None, + CDAttributes.top_temperature: None, + CDAttributes.bottom_temperature: None, + CDAttributes.wind: None, + CDAttributes.eco: None, + CDAttributes.smart_grid: None, + CDAttributes.multi_terminal: None, + CDAttributes.mute_effect: None, + CDAttributes.mute_status: None, + CDAttributes.maintenance_reminder: None, + CDAttributes.maintain_warn_tag: None, + CDAttributes.maintain_warn: None, + CDAttributes.error_code: None, + CDAttributes.typeinfo: None, + CDAttributes.vacation_mode: False, + CDAttributes.vacation_days: 0, + CDAttributes.vacation_temperature: None, + CDAttributes.vacation_start_year: None, + CDAttributes.vacation_start_month: None, + CDAttributes.vacation_start_day: None, + CDAttributes.order1_effect: None, + CDAttributes.order2_effect: None, + CDAttributes.auto_sterilize_week: None, + CDAttributes.auto_sterilize_hour: None, + CDAttributes.auto_sterilize_minute: None, + CDAttributes.weekly_effects: None, + CDAttributes.weekly_schedule: None, + CDAttributes.daily_timer_schedule: None, + }, + ) + device.preset_modes = [ + "none", + "energy_save", + "standard", + "dual", + "smart", + ] + return device + + +def _e2_device() -> DummyDevice: + return DummyDevice( + DeviceType.E2, + attributes={ + E2Attributes.power: True, + E2Attributes.heating: True, + E2Attributes.keep_warm: True, + E2Attributes.protection: False, + E2Attributes.temperature_max: 75.0, + E2Attributes.temperature_min: 30.0, + E2Attributes.current_temperature: 21.0, + E2Attributes.target_temperature: 40.0, + E2Attributes.whole_tank_heating: True, + E2Attributes.variable_heating: True, + E2Attributes.heating_time_remaining: 50, + E2Attributes.water_consumption: None, + E2Attributes.heating_power: None, + E2Attributes.fast_hot_power: None, + E2Attributes.water_flow: None, + E2Attributes.sterilization: None, + E2Attributes.heat_water_level: None, + E2Attributes.eplus: None, + E2Attributes.memory: None, + E2Attributes.fast_wash: None, + E2Attributes.half_heat: None, + E2Attributes.summer: None, + E2Attributes.winter: None, + E2Attributes.efficient: None, + E2Attributes.night: None, + E2Attributes.screen_off: None, + E2Attributes.sleep: None, + E2Attributes.cloud: None, + E2Attributes.appoint_wash: None, + E2Attributes.now_wash: None, + E2Attributes.smart_sterilize: None, + E2Attributes.sterilize_high_temp: None, + E2Attributes.uv_sterilize: None, + E2Attributes.discharge_status: None, + E2Attributes.top_temp: None, + E2Attributes.bottom_heat: None, + E2Attributes.top_heat: None, + E2Attributes.water_cyclic: None, + E2Attributes.water_system: None, + E2Attributes.in_temperature: None, + E2Attributes.day_water_consumption: None, + E2Attributes.volume: None, + E2Attributes.rate: None, + }, + ) + + +def _e3_device() -> DummyDevice: + device = DummyDevice( + DeviceType.E3, + attributes={ + E3Attributes.power: False, + E3Attributes.burning_state: False, + E3Attributes.zero_cold_water: False, + E3Attributes.protection: False, + E3Attributes.zero_cold_pulse: False, + E3Attributes.smart_volume: False, + E3Attributes.current_temperature: 38.0, + E3Attributes.target_temperature: 40.0, + E3Attributes.temperature_min: 35.0, + E3Attributes.temperature_max: 65.0, + }, + ) + device.precision_halves = True + return device + + +def _e6_device() -> DummyDevice: + device = DummyDevice( + DeviceType.E6, + attributes={ + E6Attributes.main_power: False, + E6Attributes.heating_power: True, + E6Attributes.heating_working: None, + E6Attributes.bathing_working: None, + E6Attributes.temperature_min: [30.0, 35.0], + E6Attributes.temperature_max: [80.0, 60.0], + E6Attributes.heating_temperature: 50.0, + E6Attributes.bathing_temperature: 40.0, + E6Attributes.heating_leaving_temperature: None, + E6Attributes.bathing_leaving_temperature: None, + E6Attributes.cold_water_single: None, + E6Attributes.cold_water_dot: None, + E6Attributes.heating_modes: None, + }, + ) + device.preset_modes = [ + "normal", + "out", + "home", + "sleep", + ] + return device + + +async def _assert_service_calls( + hass: HomeAssistant, + entity_id: str, + service: str, + service_data: dict, + expected_calls: list[tuple], + device: DummyDevice, +) -> None: + """Call a water heater service and assert the fake device recorded the right call.""" + device.calls.clear() + await hass.services.async_call( + WATER_HEATER_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, **service_data}, + blocking=True, + ) + assert device.calls == expected_calls + + +@pytest.mark.parametrize( + "device", + [ + pytest.param( + _c3_device(), + id="c3", + ), + pytest.param( + _cd_device(), + id="cd", + ), + pytest.param( + _e2_device(), + id="e2", + ), + pytest.param( + _e3_device(), + id="e3", + ), + pytest.param( + _e6_device(), + id="e6", + ), + ], +) +async def test_midea_water_heater_default_state( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], + device: DummyDevice, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test water heater entities are created and service calls reach the device.""" + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.WATER_HEATER]): + await setup_integration(hass, config_entry, device) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + ("device", "services", "services_data", "expected_calls", "entity_suffix"), + [ + pytest.param( + _c3_device(), + [ + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_SET_TEMPERATURE, + ], + [ + {}, + {}, + {ATTR_TEMPERATURE: 50.0}, + ], + [ + [("set_attribute", C3Attributes.dhw_power, False)], + [("set_attribute", C3Attributes.dhw_power, True)], + [("set_attribute", C3Attributes.dhw_target_temp, 50.0)], + ], + "water_heater", + id="c3", + ), + pytest.param( + _cd_device(), + [ + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_SET_TEMPERATURE, + ], + [ + {}, + {}, + {ATTR_TEMPERATURE: 50.0}, + ], + [ + [("set_attribute", CDAttributes.power, False)], + [("set_attribute", CDAttributes.power, True)], + [("set_attribute", CDAttributes.target_temperature, 50.0)], + ], + "water_heater", + id="cd", + ), + pytest.param( + _e2_device(), + [ + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_SET_TEMPERATURE, + ], + [ + {}, + {}, + {ATTR_TEMPERATURE: 50.0}, + ], + [ + [("set_attribute", E2Attributes.power, False)], + [("set_attribute", E2Attributes.power, True)], + [("set_attribute", E2Attributes.target_temperature, 50.0)], + ], + "water_heater", + id="e2", + ), + pytest.param( + _e3_device(), + [ + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_SET_TEMPERATURE, + ], + [ + {}, + {}, + {ATTR_TEMPERATURE: 50.0}, + ], + [ + [("set_attribute", E3Attributes.power, False)], + [("set_attribute", E3Attributes.power, True)], + [("set_attribute", E3Attributes.target_temperature, 50.0)], + ], + "water_heater", + id="e3", + ), + pytest.param( + _e6_device(), + [ + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_SET_TEMPERATURE, + SERVICE_SET_AWAY_MODE, + SERVICE_SET_AWAY_MODE, + ], + [ + {}, + {}, + {ATTR_TEMPERATURE: 50.0}, + {ATTR_AWAY_MODE: True}, + {ATTR_AWAY_MODE: False}, + ], + [ + [ + ("set_attribute", E6Attributes.heating_power, False), + ], + [ + ("set_attribute", E6Attributes.heating_power, True), + ], + [ + ("set_attribute", E6Attributes.heating_temperature, 50.0), + ], + [ + ("set_attribute", E6Attributes.heating_modes, "out"), + ], + [ + ("set_attribute", E6Attributes.heating_modes, "normal"), + ], + ], + "water_heater_heating", + id="e6_heating", + ), + pytest.param( + _e6_device(), + [ + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_SET_TEMPERATURE, + ], + [ + {}, + {}, + {ATTR_TEMPERATURE: 50.0}, + ], + [ + [ + ("set_attribute", E6Attributes.main_power, False), + ], + [ + ("set_attribute", E6Attributes.main_power, True), + ], + [ + ("set_attribute", E6Attributes.bathing_temperature, 50.0), + ], + ], + "water_heater_bathing", + id="e6_bathing", + ), + ], +) +async def test_midea_water_heater_services( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], + device: DummyDevice, + services: list[str], + services_data: list[dict], + expected_calls: list[list[tuple]], + entity_suffix: str, +) -> None: + """Test water heater service calls reach the device.""" + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.WATER_HEATER]): + await setup_integration(hass, config_entry, device) + + entity_entry = entity_entries(hass, config_entry)[ + f"{TEST_DEVICE_ID}_{entity_suffix}" + ] + for i, service in enumerate(services): + await _assert_service_calls( + hass, + entity_entry.entity_id, + service, + services_data[i], + expected_calls[i], + device, + ) + + +async def test_e2_min_max_temperature_from_device( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test E2 min/max temperature are read from the device attributes.""" + device = _e2_device() + config_entry = mock_config_entry(device) + await setup_integration(hass, config_entry, device) + entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_water_heater"] + entity = hass.data[WATER_HEATER_DOMAIN].get_entity(entity_entry.entity_id) + + assert entity is not None + assert entity.min_temp == 30.0 + assert entity.max_temp == 75.0 + + +async def test_e3_min_max_temperature_from_device( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test E3 min/max temperature are read from the device attributes.""" + device = _e3_device() + config_entry = mock_config_entry(device) + await setup_integration(hass, config_entry, device) + entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_water_heater"] + entity = hass.data[WATER_HEATER_DOMAIN].get_entity(entity_entry.entity_id) + + assert entity is not None + assert entity.min_temp == 35.0 + assert entity.max_temp == 65.0 From b934734549ffcef93d7aae5dd4be753fb46cdfac Mon Sep 17 00:00:00 2001 From: Paul Tarjan Date: Thu, 10 Sep 2026 12:24:31 -0600 Subject: [PATCH 15/21] Mark Hikvision binary sensors unavailable when the event stream drops (#180225) --- .../components/hikvision/binary_sensor.py | 6 +++++ tests/components/hikvision/conftest.py | 1 + .../hikvision/test_binary_sensor.py | 25 +++++++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/homeassistant/components/hikvision/binary_sensor.py b/homeassistant/components/hikvision/binary_sensor.py index 9e1cb97f778340..c4455e7461c5fa 100644 --- a/homeassistant/components/hikvision/binary_sensor.py +++ b/homeassistant/components/hikvision/binary_sensor.py @@ -314,6 +314,12 @@ def _get_sensor_attributes(self) -> tuple[bool, Any, Any, Any]: """Get sensor attributes from camera.""" return self._camera.fetch_attributes(self._sensor_type, self._channel) + @property + @override + def available(self) -> bool: + """Return true if the device's event stream is connected.""" + return self._camera.stream_connected + @property @override def is_on(self) -> bool: diff --git a/tests/components/hikvision/conftest.py b/tests/components/hikvision/conftest.py index da827a70779317..b0c09045110619 100644 --- a/tests/components/hikvision/conftest.py +++ b/tests/components/hikvision/conftest.py @@ -123,6 +123,7 @@ def mock_hikcamera(mock_hik_get_channels: MagicMock) -> Generator[MagicMock]: "2024-01-01T00:00:00Z", ) camera.get_event_triggers.return_value = {} + camera.stream_connected = True # pyHik 0.4.0 methods camera.get_channels.return_value = [1] diff --git a/tests/components/hikvision/test_binary_sensor.py b/tests/components/hikvision/test_binary_sensor.py index 5cc742971e1c39..74b9cf19a22eef 100644 --- a/tests/components/hikvision/test_binary_sensor.py +++ b/tests/components/hikvision/test_binary_sensor.py @@ -19,6 +19,7 @@ CONF_SSL, CONF_USERNAME, STATE_OFF, + STATE_UNAVAILABLE, Platform, ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant @@ -394,3 +395,27 @@ async def test_binary_sensor_update_callback( state = hass.states.get("binary_sensor.front_camera_motion") assert state is not None assert state.state == "on" + + +async def test_binary_sensor_unavailable_when_stream_disconnected( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hikcamera: MagicMock, +) -> None: + """Test sensors go unavailable when the event stream disconnects.""" + camera = mock_hikcamera.return_value + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("binary_sensor.front_camera_motion") + assert state is not None + assert state.state == STATE_OFF + + # pyhik notifies every registered callback when the stream drops + camera.stream_connected = False + callback_func = camera.add_update_callback.call_args_list[0][0][0] + callback_func("stream disconnected") + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.front_camera_motion") + assert state is not None + assert state.state == STATE_UNAVAILABLE From 2a2babceabcd1750bd12f717315af493bc41889e Mon Sep 17 00:00:00 2001 From: Paul Tarjan Date: Thu, 10 Sep 2026 12:26:12 -0600 Subject: [PATCH 16/21] Add Hikvision event platform for smart event detection targets (#180841) --- .../components/hikvision/__init__.py | 2 +- homeassistant/components/hikvision/event.py | 169 ++++++++++ .../components/hikvision/strings.json | 41 +++ tests/components/hikvision/conftest.py | 1 + .../hikvision/test_binary_sensor.py | 2 + tests/components/hikvision/test_event.py | 294 ++++++++++++++++++ 6 files changed, 508 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/hikvision/event.py create mode 100644 tests/components/hikvision/test_event.py diff --git a/homeassistant/components/hikvision/__init__.py b/homeassistant/components/hikvision/__init__.py index cc45552d5dc605..34525036cc11b8 100644 --- a/homeassistant/components/hikvision/__init__.py +++ b/homeassistant/components/hikvision/__init__.py @@ -26,7 +26,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.CAMERA] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CAMERA, Platform.EVENT] @dataclass diff --git a/homeassistant/components/hikvision/event.py b/homeassistant/components/hikvision/event.py new file mode 100644 index 00000000000000..2a0a9125480fb5 --- /dev/null +++ b/homeassistant/components/hikvision/event.py @@ -0,0 +1,169 @@ +"""Support for Hikvision smart events represented as event entities.""" + +import logging +from typing import Any, override + +from pyhik.constants import SENSOR_MAP + +from homeassistant.components.event import ( + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HikvisionConfigEntry +from .entity import HikvisionEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + +# Smart events classify what tripped them. Firmware that does not classify, and +# every trip of a non-smart event, reports no target at all. +DETECTION_TARGETS = ("human", "pet", "vehicle") +EVENT_TYPE_TRIGGERED = "triggered" +EVENT_TYPES = [EVENT_TYPE_TRIGGERED, *DETECTION_TARGETS] + +# Keyed by the friendly names pyhik emits in `current_event_states`, like the +# binary sensor descriptions, so both platforms name the same event the same +# way. Only the events that can carry a detection target are listed; the rest +# are already fully described by their binary sensor. +EVENT_DESCRIPTIONS: dict[str, EventEntityDescription] = { + SENSOR_MAP["vmd"]: EventEntityDescription( + key="motion", + translation_key="motion", + device_class=EventDeviceClass.MOTION, + event_types=EVENT_TYPES, + ), + SENSOR_MAP["linedetection"]: EventEntityDescription( + key="line_crossing", + translation_key="line_crossing", + device_class=EventDeviceClass.MOTION, + event_types=EVENT_TYPES, + ), + SENSOR_MAP["fielddetection"]: EventEntityDescription( + key="field_detection", + translation_key="field_detection", + device_class=EventDeviceClass.MOTION, + event_types=EVENT_TYPES, + ), +} + + +def event_type_for_target(detection_target: str | None) -> str: + """Return the event type for a pyhik detection target.""" + if detection_target is None: + return EVENT_TYPE_TRIGGERED + if detection_target not in DETECTION_TARGETS: + _LOGGER.warning( + "Unknown Hikvision detection target '%s', please report this at " + "https://github.com/home-assistant/core/issues", + detection_target, + ) + return EVENT_TYPE_TRIGGERED + return detection_target + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HikvisionConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Hikvision events from a config entry.""" + sensors = entry.runtime_data.camera.current_event_states + if not sensors: + return + + entities: list[HikvisionEvent] = [] + for sensor_type, channel_list in sensors.items(): + description = EVENT_DESCRIPTIONS.get(sensor_type) + if description is None: + continue + # pyhik can report the same channel more than once for a sensor type + # (e.g. when a channel has several notification methods enabled), so + # deduplicate on the channel to avoid colliding unique IDs. + seen_channels: set[int] = set() + for channel_info in channel_list: + channel = channel_info[1] + if channel in seen_channels: + continue + seen_channels.add(channel) + entities.append( + HikvisionEvent( + hass=hass, + entry=entry, + description=description, + sensor_type=sensor_type, + channel=channel, + ) + ) + + async_add_entities(entities) + + +class HikvisionEvent(HikvisionEntity, EventEntity): + """Representation of a Hikvision event.""" + + _attr_should_poll = False + entity_description: EventEntityDescription + + def __init__( + self, + hass: HomeAssistant, + entry: HikvisionConfigEntry, + description: EventEntityDescription, + sensor_type: str, + channel: int, + ) -> None: + """Initialize the event entity.""" + super().__init__(hass, entry, channel) + self.entity_description = description + self._sensor_type = sensor_type + + self._attr_unique_id = f"{self._data.device_id}_{sensor_type}_{channel}" + + # pyhik routes an update to the callbacks registered under this exact + # identifier and passes it back as the callback's message. + self._callback_id = f"{self._data.device_id}.{sensor_type}.{channel}" + + # An event already active at startup must not be replayed as new. + self._is_on = self._get_sensor_attributes()[0] + + def _get_sensor_attributes(self) -> tuple[bool, Any, Any, Any, str | None]: + """Get sensor attributes from camera.""" + return self._camera.fetch_attributes(self._sensor_type, self._channel) + + @property + @override + def available(self) -> bool: + """Return true if the device's event stream is connected.""" + return self._camera.stream_connected + + @override + async def async_added_to_hass(self) -> None: + """Register callback when entity is added.""" + await super().async_added_to_hass() + + self._camera.add_update_callback(self._update_callback, self._callback_id) + + def _update_callback(self, msg: str) -> None: + """Handle an update from pyhik's event stream thread.""" + # Read the state on the callback thread: a trip that has already ended + # by the time the event loop runs would otherwise be read as inactive + # by both handlers and never fire. + self.hass.loop.call_soon_threadsafe( + self._async_handle_update, self._get_sensor_attributes() + ) + + @callback + def _async_handle_update( + self, attributes: tuple[bool, Any, Any, Any, str | None] + ) -> None: + """Trigger an event when the underlying event has turned on.""" + is_on = attributes[0] + if is_on and not self._is_on: + self._trigger_event(event_type_for_target(attributes[4])) + self._is_on = is_on + self.async_write_ha_state() diff --git a/homeassistant/components/hikvision/strings.json b/homeassistant/components/hikvision/strings.json index 673c9d5c761284..f1a5aa05cd1096 100644 --- a/homeassistant/components/hikvision/strings.json +++ b/homeassistant/components/hikvision/strings.json @@ -90,6 +90,47 @@ "video_mismatch": { "name": "Video mismatch" } + }, + "event": { + "field_detection": { + "name": "[%key:component::hikvision::entity::binary_sensor::field_detection::name%]", + "state_attributes": { + "event_type": { + "state": { + "human": "Human", + "pet": "Pet", + "triggered": "Triggered", + "vehicle": "Vehicle" + } + } + } + }, + "line_crossing": { + "name": "[%key:component::hikvision::entity::binary_sensor::line_crossing::name%]", + "state_attributes": { + "event_type": { + "state": { + "human": "[%key:component::hikvision::entity::event::field_detection::state_attributes::event_type::state::human%]", + "pet": "[%key:component::hikvision::entity::event::field_detection::state_attributes::event_type::state::pet%]", + "triggered": "[%key:component::hikvision::entity::event::field_detection::state_attributes::event_type::state::triggered%]", + "vehicle": "[%key:component::hikvision::entity::event::field_detection::state_attributes::event_type::state::vehicle%]" + } + } + } + }, + "motion": { + "name": "[%key:component::event::entity_component::motion::name%]", + "state_attributes": { + "event_type": { + "state": { + "human": "[%key:component::hikvision::entity::event::field_detection::state_attributes::event_type::state::human%]", + "pet": "[%key:component::hikvision::entity::event::field_detection::state_attributes::event_type::state::pet%]", + "triggered": "[%key:component::hikvision::entity::event::field_detection::state_attributes::event_type::state::triggered%]", + "vehicle": "[%key:component::hikvision::entity::event::field_detection::state_attributes::event_type::state::vehicle%]" + } + } + } + } } }, "issues": { diff --git a/tests/components/hikvision/conftest.py b/tests/components/hikvision/conftest.py index b0c09045110619..ba34c682e13bfb 100644 --- a/tests/components/hikvision/conftest.py +++ b/tests/components/hikvision/conftest.py @@ -121,6 +121,7 @@ def mock_hikcamera(mock_hik_get_channels: MagicMock) -> Generator[MagicMock]: None, None, "2024-01-01T00:00:00Z", + None, ) camera.get_event_triggers.return_value = {} camera.stream_connected = True diff --git a/tests/components/hikvision/test_binary_sensor.py b/tests/components/hikvision/test_binary_sensor.py index 74b9cf19a22eef..ba9537a9e6e49e 100644 --- a/tests/components/hikvision/test_binary_sensor.py +++ b/tests/components/hikvision/test_binary_sensor.py @@ -220,6 +220,7 @@ async def test_binary_sensor_state_on( None, None, "2024-01-01T12:00:00Z", + None, ) await setup_integration(hass, mock_config_entry) @@ -378,6 +379,7 @@ async def test_binary_sensor_update_callback( None, None, "2024-01-01T12:00:00Z", + None, ) # Get the registered callback and call it diff --git a/tests/components/hikvision/test_event.py b/tests/components/hikvision/test_event.py new file mode 100644 index 00000000000000..b1e82fd42712c3 --- /dev/null +++ b/tests/components/hikvision/test_event.py @@ -0,0 +1,294 @@ +"""Test Hikvision events.""" + +from collections.abc import Callable +from unittest.mock import MagicMock + +from pyhik.constants import SENSOR_MAP +import pytest + +from homeassistant.components.event import ( + ATTR_EVENT_TYPE, + ATTR_EVENT_TYPES, + DOMAIN as EVENT_DOMAIN, + EventDeviceClass, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .conftest import TEST_DEVICE_ID + +from tests.common import MockConfigEntry + +MOTION_ENTITY_ID = "event.front_camera_motion" +LINE_CROSSING_ENTITY_ID = "event.front_camera_line_crossing" +MOTION_CALLBACK_ID = f"{TEST_DEVICE_ID}.{SENSOR_MAP['vmd']}.1" +TEST_TRIP_TIME = "2024-01-01T12:00:00Z" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Platforms, which should be loaded during the test.""" + return [Platform.EVENT] + + +def get_callbacks(mock_hikcamera: MagicMock) -> dict[str, Callable[[str], None]]: + """Return the update callbacks pyhik was handed, keyed by their ID.""" + return { + call.args[1]: call.args[0] + for call in mock_hikcamera.return_value.add_update_callback.call_args_list + } + + +def set_event_state( + mock_hikcamera: MagicMock, is_on: bool, detection_target: str | None +) -> None: + """Set the attribute tuple pyhik reports for the event.""" + mock_hikcamera.return_value.fetch_attributes.return_value = ( + is_on, + 1, + 1, + TEST_TRIP_TIME, + detection_target, + ) + + +async def test_events_created( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hikcamera: MagicMock, +) -> None: + """Test an event entity is created for each smart event.""" + await setup_integration(hass, mock_config_entry) + + assert set(hass.states.async_entity_ids(EVENT_DOMAIN)) == { + MOTION_ENTITY_ID, + LINE_CROSSING_ENTITY_ID, + } + + state = hass.states.get(MOTION_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + assert state.attributes[ATTR_DEVICE_CLASS] == EventDeviceClass.MOTION + assert state.attributes[ATTR_EVENT_TYPES] == [ + "triggered", + "human", + "pet", + "vehicle", + ] + + +async def test_events_not_created_for_non_smart_events( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hikcamera: MagicMock, +) -> None: + """Test events that never carry a detection target get no event entity.""" + mock_hikcamera.return_value.current_event_states = { + SENSOR_MAP["vmd"]: [(False, 1)], + SENSOR_MAP["diskfull"]: [(False, 1)], + SENSOR_MAP["tamperdetection"]: [(False, 1)], + } + + await setup_integration(hass, mock_config_entry) + + assert hass.states.async_entity_ids(EVENT_DOMAIN) == [MOTION_ENTITY_ID] + + +async def test_events_no_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hikcamera: MagicMock, +) -> None: + """Test setup when the device reports no events.""" + mock_hikcamera.return_value.current_event_states = None + + await setup_integration(hass, mock_config_entry) + + assert hass.states.async_entity_ids(EVENT_DOMAIN) == [] + + +@pytest.mark.parametrize( + ("detection_target", "expected_event_type"), + [ + pytest.param("human", "human", id="human"), + pytest.param("vehicle", "vehicle", id="vehicle"), + pytest.param("pet", "pet", id="pet"), + pytest.param(None, "triggered", id="no_detection_target"), + pytest.param("bicycle", "triggered", id="unrecognized_detection_target"), + ], +) +async def test_event_triggered_with_detection_target( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hikcamera: MagicMock, + detection_target: str | None, + expected_event_type: str, +) -> None: + """Test the detection target is reported as the event type.""" + await setup_integration(hass, mock_config_entry) + + set_event_state(mock_hikcamera, True, detection_target) + get_callbacks(mock_hikcamera)[MOTION_CALLBACK_ID]("motion detected") + await hass.async_block_till_done() + + state = hass.states.get(MOTION_ENTITY_ID) + assert state is not None + assert state.state != STATE_UNKNOWN + assert state.attributes[ATTR_EVENT_TYPE] == expected_event_type + # The detection target is the event type, never an extra state attribute. + assert "detection_target" not in state.attributes + + +async def test_unrecognized_detection_target_logs_warning( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hikcamera: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a detection target the integration does not know about is reported.""" + await setup_integration(hass, mock_config_entry) + + set_event_state(mock_hikcamera, True, "bicycle") + get_callbacks(mock_hikcamera)[MOTION_CALLBACK_ID]("motion detected") + await hass.async_block_till_done() + + assert "Unknown Hikvision detection target 'bicycle'" in caplog.text + + +async def test_event_only_triggered_on_a_new_trip( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hikcamera: MagicMock, +) -> None: + """Test updates while the event stays active do not trigger a new event.""" + await setup_integration(hass, mock_config_entry) + callbacks = get_callbacks(mock_hikcamera) + + set_event_state(mock_hikcamera, True, "human") + callbacks[MOTION_CALLBACK_ID]("motion detected") + await hass.async_block_till_done() + + state = hass.states.get(MOTION_ENTITY_ID) + assert state is not None + assert state.attributes[ATTR_EVENT_TYPE] == "human" + + # pyhik keeps updating while the event is active; that is the same trip. + set_event_state(mock_hikcamera, True, "vehicle") + callbacks[MOTION_CALLBACK_ID]("motion detected") + await hass.async_block_till_done() + + state = hass.states.get(MOTION_ENTITY_ID) + assert state is not None + assert state.attributes[ATTR_EVENT_TYPE] == "human" + + set_event_state(mock_hikcamera, False, None) + callbacks[MOTION_CALLBACK_ID]("motion cleared") + await hass.async_block_till_done() + + set_event_state(mock_hikcamera, True, "vehicle") + callbacks[MOTION_CALLBACK_ID]("motion detected") + await hass.async_block_till_done() + + state = hass.states.get(MOTION_ENTITY_ID) + assert state is not None + assert state.attributes[ATTR_EVENT_TYPE] == "vehicle" + + +async def test_event_active_at_setup_is_not_replayed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hikcamera: MagicMock, +) -> None: + """Test an event already active when the entity is added does not fire.""" + set_event_state(mock_hikcamera, True, "human") + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(MOTION_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + +async def test_event_unavailable_when_stream_disconnected( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hikcamera: MagicMock, +) -> None: + """Test events go unavailable when the event stream disconnects.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(MOTION_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + # pyhik notifies every registered callback when the stream drops + mock_hikcamera.return_value.stream_connected = False + get_callbacks(mock_hikcamera)[MOTION_CALLBACK_ID]("stream disconnected") + await hass.async_block_till_done() + + state = hass.states.get(MOTION_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_event_fires_for_a_trip_that_ends_before_the_loop_runs( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hikcamera: MagicMock, +) -> None: + """Test a trip is not lost when it ends before the event loop catches up.""" + await setup_integration(hass, mock_config_entry) + callbacks = get_callbacks(mock_hikcamera) + + # Both callbacks land on pyhik's thread before the loop runs either of them. + set_event_state(mock_hikcamera, True, "human") + callbacks[MOTION_CALLBACK_ID]("motion detected") + set_event_state(mock_hikcamera, False, None) + callbacks[MOTION_CALLBACK_ID]("motion cleared") + await hass.async_block_till_done() + + state = hass.states.get(MOTION_ENTITY_ID) + assert state is not None + assert state.attributes[ATTR_EVENT_TYPE] == "human" + + +@pytest.mark.parametrize("amount_of_channels", [2]) +async def test_event_duplicate_channels_deduplicated( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hikcamera: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test duplicate channel entries do not create colliding unique IDs.""" + mock_hikcamera.return_value.get_type = "NVR" + mock_hikcamera.return_value.current_event_states = { + SENSOR_MAP["linedetection"]: [ + (False, 1), + (False, 1), + (False, 1), + (False, 2), + (False, 2), + ], + } + + await setup_integration(hass, mock_config_entry) + + assert len(hass.states.async_entity_ids(EVENT_DOMAIN)) == 2 + + unique_ids = { + entry.unique_id + for entry in er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + } + assert unique_ids == { + f"{TEST_DEVICE_ID}_{SENSOR_MAP['linedetection']}_1", + f"{TEST_DEVICE_ID}_{SENSOR_MAP['linedetection']}_2", + } From ea1a91ac784a508aea4bc1d7b13397173c08fa2f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:48:28 +0200 Subject: [PATCH 17/21] Move service registration to async_setup in modbus (#181845) --- homeassistant/components/modbus/modbus.py | 101 ++---------- homeassistant/components/modbus/services.py | 119 +++++++++++++- homeassistant/components/modbus/strings.json | 5 + tests/components/modbus/test_services.py | 164 +++++++++++++++++++ 4 files changed, 300 insertions(+), 89 deletions(-) create mode 100644 tests/components/modbus/test_services.py diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 63beec37f57aee..6a7982757ffab9 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -12,10 +12,8 @@ from pymodbus.exceptions import ModbusException from pymodbus.framer import FramerType from pymodbus.pdu import ModbusPDU -import voluptuous as vol from homeassistant.const import ( - ATTR_STATE, CONF_DELAY, CONF_HOST, CONF_METHOD, @@ -25,18 +23,11 @@ CONF_TYPE, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import Event, HomeAssistant, ServiceCall -from homeassistant.helpers import config_validation as cv +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers.discovery import async_load_platform -from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType from .const import ( - ATTR_ADDRESS, - ATTR_HUB, - ATTR_SLAVE, - ATTR_UNIT, - ATTR_VALUE, CALL_TYPE_COIL, CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_HOLDING, @@ -51,17 +42,12 @@ CONF_PARITY, CONF_STOPBITS, DATA_MODBUS_HUBS, - DEFAULT_HUB, DEVICE_ID, DOMAIN, LOGGER, PLATFORMS, RTUOVERTCP, SERIAL, - SERVICE_STOP, - SERVICE_WRITE_COIL, - SERVICE_WRITE_REGISTER, - SIGNAL_STOP_ENTITY, TCP, UDP, ) @@ -128,6 +114,20 @@ async def async_modbus_setup( config: ConfigType, ) -> bool: """Set up Modbus component.""" + if await _async_modbus_setup(hass, config): + return True + + # Hubs are stored as they are created, so a failure part way through leaves + # unusable ones behind. Drop them, so their presence means they are usable. + hass.data.pop(DATA_MODBUS_HUBS, None) + return False + + +async def _async_modbus_setup( + hass: HomeAssistant, + config: ConfigType, +) -> bool: + """Set up the Modbus hubs and their platforms.""" if config[DOMAIN]: config[DOMAIN] = check_config(hass, config[DOMAIN]) @@ -165,77 +165,6 @@ async def async_stop_modbus(event: Event) -> None: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_modbus) - def _get_service_call_details( - service: ServiceCall, - ) -> tuple[ModbusHub, int, int]: - """Return the details required to process the service call.""" - device_address = service.data.get(ATTR_SLAVE, service.data.get(ATTR_UNIT, 1)) - address = service.data[ATTR_ADDRESS] - hub = hub_collect[service.data[ATTR_HUB]] - return (hub, device_address, address) - - async def async_write_register(service: ServiceCall) -> None: - """Write Modbus registers.""" - hub, device_address, address = _get_service_call_details(service) - - value = service.data[ATTR_VALUE] - if isinstance(value, list): - await hub.async_pb_call( - device_address, address, value, CALL_TYPE_WRITE_REGISTERS - ) - else: - await hub.async_pb_call( - device_address, address, value, CALL_TYPE_WRITE_REGISTER - ) - - async def async_write_coil(service: ServiceCall) -> None: - """Write Modbus coil.""" - hub, device_address, address = _get_service_call_details(service) - - state = service.data[ATTR_STATE] - - if isinstance(state, list): - await hub.async_pb_call( - device_address, address, state, CALL_TYPE_WRITE_COILS - ) - else: - await hub.async_pb_call( - device_address, address, state, CALL_TYPE_WRITE_COIL - ) - - for x_write in ( - (SERVICE_WRITE_REGISTER, async_write_register, ATTR_VALUE, cv.positive_int), - (SERVICE_WRITE_COIL, async_write_coil, ATTR_STATE, cv.boolean), - ): - hass.services.async_register( - DOMAIN, - x_write[0], - x_write[1], - schema=vol.Schema( - { - vol.Optional(ATTR_HUB, default=DEFAULT_HUB): cv.string, - vol.Exclusive(ATTR_SLAVE, "unit"): cv.positive_int, - vol.Exclusive(ATTR_UNIT, "unit"): cv.positive_int, - vol.Required(ATTR_ADDRESS): cv.positive_int, - vol.Required(x_write[2]): vol.Any( - cv.positive_int, vol.All(cv.ensure_list, [x_write[3]]) - ), - } - ), - ) - - async def async_stop_hub(service: ServiceCall) -> None: - """Stop Modbus hub.""" - async_dispatcher_send(hass, SIGNAL_STOP_ENTITY) - hub = hub_collect[service.data[ATTR_HUB]] - await hub.async_close() - - hass.services.async_register( - DOMAIN, - SERVICE_STOP, - async_stop_hub, - schema=vol.Schema({vol.Required(ATTR_HUB): cv.string}), - ) return True diff --git a/homeassistant/components/modbus/services.py b/homeassistant/components/modbus/services.py index 910a63a119f4e9..ff49255dbdea40 100644 --- a/homeassistant/components/modbus/services.py +++ b/homeassistant/components/modbus/services.py @@ -1,13 +1,108 @@ """Support for Modbus services.""" -from homeassistant.const import SERVICE_RELOAD +from collections.abc import Callable +from typing import Any + +import voluptuous as vol + +from homeassistant.const import ATTR_STATE, SERVICE_RELOAD from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.service import async_register_admin_service -from .const import DATA_MODBUS_HUBS, DOMAIN, LOGGER -from .modbus import async_modbus_setup +from .const import ( + ATTR_ADDRESS, + ATTR_HUB, + ATTR_SLAVE, + ATTR_UNIT, + ATTR_VALUE, + CALL_TYPE_WRITE_COIL, + CALL_TYPE_WRITE_COILS, + CALL_TYPE_WRITE_REGISTER, + CALL_TYPE_WRITE_REGISTERS, + DATA_MODBUS_HUBS, + DEFAULT_HUB, + DOMAIN, + LOGGER, + SERVICE_STOP, + SERVICE_WRITE_COIL, + SERVICE_WRITE_REGISTER, + SIGNAL_STOP_ENTITY, +) +from .modbus import ModbusHub, async_modbus_setup + + +def _write_service_schema(attr: str, validator: Callable[[Any], Any]) -> vol.Schema: + """Return the schema shared by the write actions.""" + return vol.Schema( + { + vol.Optional(ATTR_HUB, default=DEFAULT_HUB): cv.string, + vol.Exclusive(ATTR_SLAVE, "unit"): cv.positive_int, + vol.Exclusive(ATTR_UNIT, "unit"): cv.positive_int, + vol.Required(ATTR_ADDRESS): cv.positive_int, + vol.Required(attr): vol.Any( + cv.positive_int, vol.All(cv.ensure_list, [validator]) + ), + } + ) + + +def _get_hubs(hass: HomeAssistant) -> dict[str, ModbusHub]: + """Return the configured Modbus hubs, raising if Modbus is not set up.""" + if not (hubs := hass.data.get(DATA_MODBUS_HUBS)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_loaded", + ) + + return hubs + + +def _get_service_call_details(service: ServiceCall) -> tuple[ModbusHub, int, int]: + """Return the details required to process the service call.""" + device_address = service.data.get(ATTR_SLAVE, service.data.get(ATTR_UNIT, 1)) + address = service.data[ATTR_ADDRESS] + hub = _get_hubs(service.hass)[service.data[ATTR_HUB]] + return (hub, device_address, address) + + +async def _async_write_register(service: ServiceCall) -> None: + """Write Modbus registers.""" + hub, device_address, address = _get_service_call_details(service) + + value = service.data[ATTR_VALUE] + if isinstance(value, list): + await hub.async_pb_call( + device_address, address, value, CALL_TYPE_WRITE_REGISTERS + ) + else: + await hub.async_pb_call( + device_address, address, value, CALL_TYPE_WRITE_REGISTER + ) + + +async def _async_write_coil(service: ServiceCall) -> None: + """Write Modbus coil.""" + hub, device_address, address = _get_service_call_details(service) + + state = service.data[ATTR_STATE] + + if isinstance(state, list): + await hub.async_pb_call(device_address, address, state, CALL_TYPE_WRITE_COILS) + else: + await hub.async_pb_call(device_address, address, state, CALL_TYPE_WRITE_COIL) + + +async def _async_stop_hub(service: ServiceCall) -> None: + """Stop Modbus hub.""" + hass = service.hass + hub = _get_hubs(hass)[service.data[ATTR_HUB]] + async_dispatcher_send(hass, SIGNAL_STOP_ENTITY) + await hub.async_close() async def _async_reload_config(call: ServiceCall) -> None: @@ -35,3 +130,21 @@ async def _async_reload_config(call: ServiceCall) -> None: def async_setup_services(hass: HomeAssistant) -> None: """Register the Modbus services.""" async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, _async_reload_config) + hass.services.async_register( + DOMAIN, + SERVICE_WRITE_REGISTER, + _async_write_register, + schema=_write_service_schema(ATTR_VALUE, cv.positive_int), + ) + hass.services.async_register( + DOMAIN, + SERVICE_WRITE_COIL, + _async_write_coil, + schema=_write_service_schema(ATTR_STATE, cv.boolean), + ) + hass.services.async_register( + DOMAIN, + SERVICE_STOP, + _async_stop_hub, + schema=vol.Schema({vol.Required(ATTR_HUB): cv.string}), + ) diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index 5d93f909fe00db..f57b51bfcbfc5d 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -1,4 +1,9 @@ { + "exceptions": { + "not_loaded": { + "message": "Modbus is not loaded, so this action cannot be performed." + } + }, "issues": { "duplicate_entity_entry": { "description": "An address can only be associated with one entity. Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue.", diff --git a/tests/components/modbus/test_services.py b/tests/components/modbus/test_services.py new file mode 100644 index 00000000000000..52a96de3e78eb3 --- /dev/null +++ b/tests/components/modbus/test_services.py @@ -0,0 +1,164 @@ +"""Tests for the Modbus services.""" + +from unittest.mock import patch + +import pytest + +from homeassistant import config as hass_config +from homeassistant.components.modbus.const import ( + ATTR_ADDRESS, + ATTR_HUB, + ATTR_VALUE, + DATA_MODBUS_HUBS, + DEFAULT_HUB, + DOMAIN, + SERVICE_STOP, + SERVICE_WRITE_COIL, + SERVICE_WRITE_REGISTER, +) +from homeassistant.const import ( + ATTR_STATE, + CONF_ADDRESS, + CONF_HOST, + CONF_NAME, + CONF_PORT, + CONF_SENSORS, + CONF_TYPE, + SERVICE_RELOAD, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.setup import async_setup_component + +from tests.common import get_fixture_path + +HUB_CONFIG = { + DOMAIN: [ + { + CONF_NAME: DEFAULT_HUB, + CONF_TYPE: "tcp", + CONF_HOST: "modbusHost", + CONF_PORT: 5501, + CONF_SENSORS: [{CONF_NAME: "dummy", CONF_ADDRESS: 9999}], + } + ] +} + +SERVICES = ( + SERVICE_RELOAD, + SERVICE_WRITE_REGISTER, + SERVICE_WRITE_COIL, + SERVICE_STOP, +) + +# The actions that need a configured hub, and a minimal valid payload for each. +HUB_SERVICES = [ + pytest.param( + SERVICE_WRITE_REGISTER, + {ATTR_HUB: DEFAULT_HUB, ATTR_ADDRESS: 1, ATTR_VALUE: 1}, + id="write_register", + ), + pytest.param( + SERVICE_WRITE_COIL, + {ATTR_HUB: DEFAULT_HUB, ATTR_ADDRESS: 1, ATTR_STATE: True}, + id="write_coil", + ), + pytest.param(SERVICE_STOP, {ATTR_HUB: DEFAULT_HUB}, id="stop"), +] + + +@pytest.mark.parametrize("service", SERVICES) +async def test_services_registered_without_yaml( + hass: HomeAssistant, service: str +) -> None: + """Test the actions are registered without a Modbus YAML section.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + assert hass.services.has_service(DOMAIN, service) + + +@pytest.mark.parametrize(("service", "data"), HUB_SERVICES) +async def test_service_without_yaml_raises( + hass: HomeAssistant, service: str, data: dict +) -> None: + """Test the hub actions raise without a Modbus YAML section.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + assert DATA_MODBUS_HUBS not in hass.data + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call(DOMAIN, service, data, blocking=True) + + assert err.value.translation_domain == DOMAIN + assert err.value.translation_key == "not_loaded" + assert "Modbus is not loaded" in str(err.value) + + +@pytest.mark.parametrize(("service", "data"), HUB_SERVICES) +async def test_service_without_hubs_raises( + hass: HomeAssistant, service: str, data: dict +) -> None: + """Test the hub actions raise when Modbus is configured without any hub.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: []}) + await hass.async_block_till_done() + + assert hass.data[DATA_MODBUS_HUBS] == {} + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call(DOMAIN, service, data, blocking=True) + + assert err.value.translation_key == "not_loaded" + + +@pytest.mark.parametrize(("service", "data"), HUB_SERVICES) +async def test_service_after_failed_setup_raises( + hass: HomeAssistant, service: str, data: dict +) -> None: + """Test the hub actions raise after a failed hub setup. + + Hubs are stored before their setup is awaited, so a failure must drop them + again; stop would otherwise raise AttributeError on the unset _connect_task. + """ + with patch( + "homeassistant.components.modbus.modbus.ModbusHub.async_setup", + return_value=False, + ): + assert not await async_setup_component(hass, DOMAIN, HUB_CONFIG) + await hass.async_block_till_done() + + assert DATA_MODBUS_HUBS not in hass.data + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call(DOMAIN, service, data, blocking=True) + + assert err.value.translation_key == "not_loaded" + + +@pytest.mark.parametrize(("service", "data"), HUB_SERVICES) +async def test_service_after_failed_reload_raises( + hass: HomeAssistant, service: str, data: dict +) -> None: + """Test the hub actions raise after a reload failed to set the hubs up.""" + assert await async_setup_component(hass, DOMAIN, HUB_CONFIG) + await hass.async_block_till_done() + assert hass.data[DATA_MODBUS_HUBS] + + yaml_path = get_fixture_path("configuration.yaml", DOMAIN) + with ( + patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path), + patch( + "homeassistant.components.modbus.modbus.ModbusHub.async_setup", + return_value=False, + ), + ): + await hass.services.async_call(DOMAIN, SERVICE_RELOAD, {}, blocking=True) + await hass.async_block_till_done() + + assert DATA_MODBUS_HUBS not in hass.data + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call(DOMAIN, service, data, blocking=True) + + assert err.value.translation_key == "not_loaded" From 7b2dc0278249b131bc341bae46c26a76cf2f49b0 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 10 Sep 2026 20:50:35 +0200 Subject: [PATCH 18/21] Log device availability transitions in Midea (#181785) --- homeassistant/components/midea/__init__.py | 15 +++++++++ .../components/midea/quality_scale.yaml | 2 +- tests/components/midea/test_init.py | 32 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/midea/__init__.py b/homeassistant/components/midea/__init__.py index 7fea47831bd4fe..044f657837872f 100644 --- a/homeassistant/components/midea/__init__.py +++ b/homeassistant/components/midea/__init__.py @@ -128,6 +128,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: MideaConfigEntry) -> boo translation_placeholders={"device_id": str(device_id)}, ) + unavailable_logged = False + + def _log_availability(_status: Mapping[str, Any]) -> None: + """Log once when the device goes offline and once when it is back.""" + nonlocal unavailable_logged + if not device.available and not unavailable_logged: + LOGGER.info("Device %s is unavailable", device_id) + unavailable_logged = True + elif device.available and unavailable_logged: + LOGGER.info("Device %s is back online", device_id) + unavailable_logged = False + + device.register_update(_log_availability) + entry.async_on_unload(partial(device.unregister_update, _log_availability)) + # The library's reconnect loop keeps retrying with a growing backoff # (up to 600s) without checking for a stop request while sleeping, so # device.close() alone cannot guarantee the background thread exits diff --git a/homeassistant/components/midea/quality_scale.yaml b/homeassistant/components/midea/quality_scale.yaml index 5a6763ccc94c9c..37d5c8dbbaf2d8 100644 --- a/homeassistant/components/midea/quality_scale.yaml +++ b/homeassistant/components/midea/quality_scale.yaml @@ -36,7 +36,7 @@ rules: docs-installation-parameters: done entity-unavailable: todo integration-owner: done - log-when-unavailable: todo + log-when-unavailable: done parallel-updates: done reauthentication-flow: todo test-coverage: done diff --git a/tests/components/midea/test_init.py b/tests/components/midea/test_init.py index b5186838ae4dea..518d7ac6be90d8 100644 --- a/tests/components/midea/test_init.py +++ b/tests/components/midea/test_init.py @@ -1,8 +1,10 @@ """Tests for midea __init__.py.""" +import logging from unittest.mock import patch from midealocal.const import DeviceType, ProtocolVersion +import pytest from homeassistant.components.midea.const import CONF_SN, DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -56,6 +58,36 @@ async def test_unload_entry(hass: HomeAssistant, config_entry: MockConfigEntry) assert ("close",) in device.calls +async def test_logs_once_when_device_unavailable_and_back( + hass: HomeAssistant, + config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the device is logged once when it goes offline and once when it recovers.""" + config_entry.add_to_hass(hass) + device = DummyDevice(DeviceType.AC) + with patch( + "homeassistant.components.midea.device_selector", + return_value=device, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + unavailable_msg = f"Device {TEST_DEVICE_ID} is unavailable" + back_online_msg = f"Device {TEST_DEVICE_ID} is back online" + + with caplog.at_level(logging.INFO, logger="homeassistant.components.midea"): + device.available = False + device.notify_update({"available": False}) + device.notify_update({"available": False}) + assert caplog.text.count(unavailable_msg) == 1 + + device.available = True + device.notify_update({"available": True}) + device.notify_update({"available": True}) + assert caplog.text.count(back_online_msg) == 1 + + async def test_async_setup_entry_paths( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: From 8c0c04cebbe340412169221c18bd02684465a975 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 10 Sep 2026 20:51:57 +0200 Subject: [PATCH 19/21] Add action exceptions to Midea (#181778) --- homeassistant/components/midea/climate.py | 73 +++++++++++-------- .../components/midea/quality_scale.yaml | 2 +- 2 files changed, 45 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/midea/climate.py b/homeassistant/components/midea/climate.py index 1fd0959a81b690..67bbe271ac9429 100644 --- a/homeassistant/components/midea/climate.py +++ b/homeassistant/components/midea/climate.py @@ -43,7 +43,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, FanSpeed -from .entity import MideaConfigEntry, MideaEntity +from .entity import MideaConfigEntry, MideaEntity, midea_api_call PARALLEL_UPDATES = 0 @@ -234,12 +234,14 @@ def preset_mode(self) -> str | None: @override def turn_on(self, **kwargs: Any) -> None: """Midea Climate turn on.""" - self._device.set_attribute(attr="power", value=True) + with midea_api_call(): + self._device.set_attribute(attr="power", value=True) @override def turn_off(self, **kwargs: Any) -> None: """Midea Climate turn off.""" - self._device.set_attribute(attr="power", value=False) + with midea_api_call(): + self._device.set_attribute(attr="power", value=False) @override def set_temperature(self, **kwargs: Any) -> None: @@ -257,11 +259,12 @@ def set_temperature(self, **kwargs: Any) -> None: translation_key="unsupported_hvac_mode", translation_placeholders={"hvac_mode": hvac_mode}, ) - self._device.set_raw_target_temperature( - target_temperature=temperature, - hvac_mode=hvac_mode, - zone=self._zone, - ) + with midea_api_call(): + self._device.set_raw_target_temperature( + target_temperature=temperature, + hvac_mode=hvac_mode, + zone=self._zone, + ) @override def set_hvac_mode(self, hvac_mode: HVACMode) -> None: @@ -269,21 +272,24 @@ def set_hvac_mode(self, hvac_mode: HVACMode) -> None: if hvac_mode == HVACMode.OFF: self.turn_off() else: - self._device.set_attribute( - attr="mode", - value=self._hvac_to_protocol_mode(hvac_mode), - ) + with midea_api_call(): + self._device.set_attribute( + attr="mode", + value=self._hvac_to_protocol_mode(hvac_mode), + ) @override def set_preset_mode(self, preset_mode: str) -> None: """Midea Climate set preset mode.""" if new_attr := _PRESET_TO_ATTR.get(preset_mode): - self._device.set_attribute(attr=new_attr, value=True) + with midea_api_call(): + self._device.set_attribute(attr=new_attr, value=True) return old_mode = self.preset_mode old_attr = _PRESET_TO_ATTR.get(old_mode) if isinstance(old_mode, str) else None if old_attr: - self._device.set_attribute(attr=old_attr, value=False) + with midea_api_call(): + self._device.set_attribute(attr=old_attr, value=False) class MideaACClimate(MideaClimate): @@ -413,12 +419,14 @@ def current_humidity(self) -> float | None: def set_fan_mode(self, fan_mode: str) -> None: """Midea AC Climate set fan mode.""" fan_speed = self._fan_speeds[fan_mode] - self._device.set_attribute(attr=ACAttributes.fan_speed, value=fan_speed) + with midea_api_call(): + self._device.set_attribute(attr=ACAttributes.fan_speed, value=fan_speed) @override def set_swing_mode(self, swing_mode: str) -> None: """Midea AC Climate set swing mode.""" - self._device.set_raw_swing_mode(swing_mode) + with midea_api_call(): + self._device.set_raw_swing_mode(swing_mode) class MideaCCClimate(MideaClimate): @@ -480,15 +488,17 @@ def swing_mode(self) -> str | None: @override def set_fan_mode(self, fan_mode: str) -> None: """Midea CC Climate set fan mode.""" - self._device.set_raw_fan_mode(fan_mode) + with midea_api_call(): + self._device.set_raw_fan_mode(fan_mode) @override def set_swing_mode(self, swing_mode: str) -> None: """Midea CC Climate set swing mode.""" - self._device.set_attribute( - attr=CCAttributes.swing, - value=swing_mode == SWING_ON, - ) + with midea_api_call(): + self._device.set_attribute( + attr=CCAttributes.swing, + value=swing_mode == SWING_ON, + ) class MideaCFClimate(MideaClimate): @@ -524,10 +534,11 @@ def set_hvac_mode(self, hvac_mode: HVACMode) -> None: if hvac_mode == HVACMode.OFF: self.turn_off() else: - self._device.set_raw_target_temperature( - target_temperature=self.target_temperature or self.min_temp, - hvac_mode=hvac_mode, - ) + with midea_api_call(): + self._device.set_raw_target_temperature( + target_temperature=self.target_temperature or self.min_temp, + hvac_mode=hvac_mode, + ) @property @override @@ -633,12 +644,14 @@ def max_temp(self) -> float: @override def turn_on(self, **kwargs: Any) -> None: """Midea C3 Climate turn on.""" - self._device.set_attribute(attr=self._power_attr, value=True) + with midea_api_call(): + self._device.set_attribute(attr=self._power_attr, value=True) @override def turn_off(self, **kwargs: Any) -> None: """Midea C3 Climate turn off.""" - self._device.set_attribute(attr=self._power_attr, value=False) + with midea_api_call(): + self._device.set_attribute(attr=self._power_attr, value=False) @property @override @@ -678,7 +691,8 @@ def set_hvac_mode(self, hvac_mode: HVACMode) -> None: if hvac_mode == HVACMode.OFF: self.turn_off() else: - self._device.set_raw_hvac_mode(hvac_mode, zone=self._zone) + with midea_api_call(): + self._device.set_raw_hvac_mode(hvac_mode, zone=self._zone) class MideaFBClimate(MideaClimate): @@ -749,4 +763,5 @@ def set_hvac_mode(self, hvac_mode: HVACMode) -> None: @override def set_preset_mode(self, preset_mode: str) -> None: """Midea FB Climate set preset mode.""" - self._device.set_attribute(attr=FBAttributes.mode, value=preset_mode) + with midea_api_call(): + self._device.set_attribute(attr=FBAttributes.mode, value=preset_mode) diff --git a/homeassistant/components/midea/quality_scale.yaml b/homeassistant/components/midea/quality_scale.yaml index 37d5c8dbbaf2d8..0d6be4efe5daa3 100644 --- a/homeassistant/components/midea/quality_scale.yaml +++ b/homeassistant/components/midea/quality_scale.yaml @@ -30,7 +30,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done From e59059d63bfab19be4ce13c2cf7f21e3fa8c2385 Mon Sep 17 00:00:00 2001 From: Imou-OpenPlatform Date: Fri, 11 Sep 2026 02:59:58 +0800 Subject: [PATCH 20/21] Bump pyimouapi to 1.4.0 (#181707) --- homeassistant/components/imou/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/imou/manifest.json b/homeassistant/components/imou/manifest.json index ac093d22f337cc..4510f66c989f01 100644 --- a/homeassistant/components/imou/manifest.json +++ b/homeassistant/components/imou/manifest.json @@ -20,5 +20,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["pyimouapi==1.3.4"] + "requirements": ["pyimouapi==1.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8c885f22f4ab11..ea491cba660191 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2307,7 +2307,7 @@ pyialarm==2.2.0 pyicloud==2.6.5 # homeassistant.components.imou -pyimouapi==1.3.4 +pyimouapi==1.4.0 # homeassistant.components.insteon pyinsteon==1.6.4 From 9485e2cfee0c43f8b758bce99411b62a3a456a5d Mon Sep 17 00:00:00 2001 From: Ermanno Baschiera Date: Thu, 10 Sep 2026 21:07:44 +0200 Subject: [PATCH 21/21] Add charging mode select to Silla Prism (#181424) --- homeassistant/components/silla_prism/const.py | 2 +- .../components/silla_prism/coordinator.py | 6 +- .../components/silla_prism/select.py | 54 +++++++++++ .../components/silla_prism/strings.json | 10 ++ tests/components/silla_prism/__init__.py | 19 +++- .../silla_prism/snapshots/test_select.ambr | 62 ++++++++++++ tests/components/silla_prism/test_select.py | 96 +++++++++++++++++++ tests/components/silla_prism/test_sensor.py | 4 +- 8 files changed, 245 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/silla_prism/select.py create mode 100644 tests/components/silla_prism/snapshots/test_select.ambr create mode 100644 tests/components/silla_prism/test_select.py diff --git a/homeassistant/components/silla_prism/const.py b/homeassistant/components/silla_prism/const.py index 8486babc1ac4e2..07f18a1ad2518a 100644 --- a/homeassistant/components/silla_prism/const.py +++ b/homeassistant/components/silla_prism/const.py @@ -6,7 +6,7 @@ DOMAIN: Final = "silla_prism" -PLATFORMS: Final = [Platform.SENSOR] +PLATFORMS: Final = [Platform.SELECT, Platform.SENSOR] CONF_BASE_TOPIC: Final = "base_topic" diff --git a/homeassistant/components/silla_prism/coordinator.py b/homeassistant/components/silla_prism/coordinator.py index 8dc8922d468824..5367a70ab2d2b2 100644 --- a/homeassistant/components/silla_prism/coordinator.py +++ b/homeassistant/components/silla_prism/coordinator.py @@ -39,7 +39,7 @@ def __init__(self, hass: HomeAssistant, entry: PrismConfigEntry) -> None: """Initialize the coordinator.""" super().__init__(hass, _LOGGER, config_entry=entry, name=DOMAIN) self.base_topic: str = entry.data[CONF_BASE_TOPIC] - self.device = PrismDevice(self.base_topic) + self.device = PrismDevice(self.base_topic, publish=self._async_publish) self.device.on_status_update = self._on_status_update self.device.on_hello = self._on_hello @@ -60,6 +60,10 @@ async def _async_update_data(self) -> PrismStatus: """Return the accumulated state (populated by retained messages).""" return self.device.status + async def _async_publish(self, topic: str, payload: str) -> None: + """Publish a command built by the library.""" + await mqtt.async_publish(self.hass, topic, payload) + @callback def _message_received(self, msg: ReceiveMessage) -> None: if isinstance(msg.payload, str): diff --git a/homeassistant/components/silla_prism/select.py b/homeassistant/components/silla_prism/select.py new file mode 100644 index 00000000000000..66d0275a74a73f --- /dev/null +++ b/homeassistant/components/silla_prism/select.py @@ -0,0 +1,54 @@ +"""Select platform for the Silla Prism integration.""" + +from typing import override + +from pysillaprism import SETTABLE_MODES + +from homeassistant.components.select import SelectEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import PORT +from .coordinator import PrismConfigEntry, PrismCoordinator +from .entity import PrismEntity + +PARALLEL_UPDATES = 0 + +OPTION_TO_MODE = {mode.name.lower(): mode for mode in SETTABLE_MODES} +MODE_TO_OPTION = {mode: option for option, mode in OPTION_TO_MODE.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PrismConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Prism select entities.""" + async_add_entities([PrismModeSelect(entry.runtime_data)]) + + +class PrismModeSelect(PrismEntity, SelectEntity): + """Charging mode selector (Solar/Normal/Pause).""" + + _attr_translation_key = "charging_mode" + _attr_options = list(OPTION_TO_MODE) + + def __init__(self, coordinator: PrismCoordinator) -> None: + """Initialize the select entity.""" + super().__init__(coordinator, "charging_mode") + + @property + @override + def current_option(self) -> str | None: + """Return the current mode, or None when it is not user-settable. + + Prism also reports a load-balancing pause, which cannot be selected + back; it surfaces on the status sensor instead. + """ + mode = self.coordinator.device.status.port(PORT).mode + return MODE_TO_OPTION.get(mode) if mode is not None else None + + @override + async def async_select_option(self, option: str) -> None: + """Change the charging mode.""" + await self.coordinator.device.set_mode(OPTION_TO_MODE[option], PORT) diff --git a/homeassistant/components/silla_prism/strings.json b/homeassistant/components/silla_prism/strings.json index 2b7dce9a180d85..503268a742aaa8 100644 --- a/homeassistant/components/silla_prism/strings.json +++ b/homeassistant/components/silla_prism/strings.json @@ -28,6 +28,16 @@ } }, "entity": { + "select": { + "charging_mode": { + "name": "Charging mode", + "state": { + "normal": "[%key:common::state::normal%]", + "pause": "[%key:common::state::paused%]", + "solar": "Solar" + } + } + }, "sensor": { "error": { "name": "Error", diff --git a/tests/components/silla_prism/__init__.py b/tests/components/silla_prism/__init__.py index 8d7d15c71e98e4..3fd9389618f3fa 100644 --- a/tests/components/silla_prism/__init__.py +++ b/tests/components/silla_prism/__init__.py @@ -1,5 +1,9 @@ """Tests for the Silla Prism integration.""" +from unittest.mock import patch + +from homeassistant.components.silla_prism.const import PLATFORMS +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import RETAINED_BURST @@ -7,10 +11,17 @@ from tests.common import MockConfigEntry, async_fire_mqtt_message -async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: - """Set up the Silla Prism integration.""" - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() +async def setup_integration( + hass: HomeAssistant, + entry: MockConfigEntry, + platforms: list[Platform] | None = None, +) -> None: + """Set up the Silla Prism integration, optionally limited to some platforms.""" + with patch( + "homeassistant.components.silla_prism.PLATFORMS", platforms or PLATFORMS + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() async def fire_burst(hass: HomeAssistant) -> None: diff --git a/tests/components/silla_prism/snapshots/test_select.ambr b/tests/components/silla_prism/snapshots/test_select.ambr new file mode 100644 index 00000000000000..93ddc643b57d86 --- /dev/null +++ b/tests/components/silla_prism/snapshots/test_select.ambr @@ -0,0 +1,62 @@ +# serializer version: 1 +# name: test_selects[select.silla_prism_charging_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'solar', + 'normal', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.silla_prism_charging_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charging mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charging mode', + 'platform': 'silla_prism', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_mode', + 'unique_id': 'prism_charging_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[select.silla_prism_charging_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Silla Prism Charging mode', + : list([ + 'solar', + 'normal', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'select.silla_prism_charging_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal', + }) +# --- diff --git a/tests/components/silla_prism/test_select.py b/tests/components/silla_prism/test_select.py new file mode 100644 index 00000000000000..5c3c7aa604a1ca --- /dev/null +++ b/tests/components/silla_prism/test_select.py @@ -0,0 +1,96 @@ +"""Test the Silla Prism selects.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import fire_burst, setup_integration + +from tests.common import MockConfigEntry, async_fire_mqtt_message, snapshot_platform +from tests.typing import MqttMockHAClient + +CHARGING_MODE_ENTITY_ID = "select.silla_prism_charging_mode" +MODE_TOPIC = "prism/1/mode" +SET_MODE_TOPIC = "prism/1/command/set_mode" + + +async def test_selects( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the selects.""" + await setup_integration(hass, mock_config_entry, [Platform.SELECT]) + await fire_burst(hass) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("option", "payload"), + [("solar", "1"), ("normal", "2"), ("pause", "3")], +) +async def test_select_option( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + mock_config_entry: MockConfigEntry, + option: str, + payload: str, +) -> None: + """Test that selecting a mode publishes the matching command.""" + await setup_integration(hass, mock_config_entry) + await fire_burst(hass) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: CHARGING_MODE_ENTITY_ID, ATTR_OPTION: option}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + SET_MODE_TOPIC, payload, 0, False, message_expiry_interval=None + ) + + +@pytest.mark.usefixtures("mqtt_mock") +async def test_mode_updates( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the selected mode follows the reported one.""" + await setup_integration(hass, mock_config_entry) + await fire_burst(hass) + + assert hass.states.get(CHARGING_MODE_ENTITY_ID).state == "normal" + + async_fire_mqtt_message(hass, MODE_TOPIC, "1") + await hass.async_block_till_done() + + assert hass.states.get(CHARGING_MODE_ENTITY_ID).state == "solar" + + +@pytest.mark.usefixtures("mqtt_mock") +async def test_mode_not_selectable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that a mode Prism cannot be put back into reads as unknown.""" + await setup_integration(hass, mock_config_entry) + await fire_burst(hass) + + # Load balancing suspended the session: reported, but not user-settable. + async_fire_mqtt_message(hass, MODE_TOPIC, "7") + await hass.async_block_till_done() + + assert hass.states.get(CHARGING_MODE_ENTITY_ID).state == STATE_UNKNOWN diff --git a/tests/components/silla_prism/test_sensor.py b/tests/components/silla_prism/test_sensor.py index 3aa198ae07f15e..cd61637b6da5fb 100644 --- a/tests/components/silla_prism/test_sensor.py +++ b/tests/components/silla_prism/test_sensor.py @@ -6,7 +6,7 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import STATE_UNKNOWN +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -33,7 +33,7 @@ async def test_sensors( ) -> None: """Test the sensors.""" freezer.move_to("2026-01-01 00:00:00+00:00") - await setup_integration(hass, mock_config_entry) + await setup_integration(hass, mock_config_entry, [Platform.SENSOR]) await fire_burst(hass) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)