diff --git a/CODEOWNERS b/CODEOWNERS index 54e9c560647d78..12df36c7bd4d7c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1338,6 +1338,8 @@ CLAUDE.md @home-assistant/core /tests/components/powerfox/ @klaasnicolaas /homeassistant/components/powerfox_local/ @klaasnicolaas /tests/components/powerfox_local/ @klaasnicolaas +/homeassistant/components/powersensor/ @bookman-dius @jmattsson +/tests/components/powersensor/ @bookman-dius @jmattsson /homeassistant/components/powerwall/ @bdraco @jrester @daniel-simpson /tests/components/powerwall/ @bdraco @jrester @daniel-simpson /homeassistant/components/prana/ @prana-dev-official diff --git a/homeassistant/components/powersensor/__init__.py b/homeassistant/components/powersensor/__init__.py new file mode 100644 index 00000000000000..490b41cc5f7077 --- /dev/null +++ b/homeassistant/components/powersensor/__init__.py @@ -0,0 +1,115 @@ +"""The Powersensor integration.""" + +import logging + +from powersensor_local import VirtualHousehold + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.loader import async_get_integration + +from .config_flow import PowersensorConfigFlow +from .const import ( + CFG_DEVICES, + CFG_ROLES, + DOMAIN, + ROLE_SOLAR, + RT_DISPATCHER, + RT_VHH, + RT_ZEROCONF, +) +from .powersensor_discovery_service import PowersensorDiscoveryService +from .powersensor_message_dispatcher import PowersensorMessageDispatcher + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +# +# config entry.data structure (version 2.2): +# { +# devices = { +# mac = { +# name =, +# display_name =, +# mac =, +# host =, +# port =, +# } +# roles = { +# mac = role, +# } +# } +# + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up integration from a config entry.""" + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = {} + + integration = await async_get_integration(hass, DOMAIN) + manifest = integration.manifest + + try: + # Create the zeroconf discovery service + zeroconf_domain: str = str(manifest["zeroconf"][0]) + zeroconf_service = PowersensorDiscoveryService(hass, zeroconf_domain) + await zeroconf_service.start() + + # Establish our virtual household + with_solar = ROLE_SOLAR in entry.data.get(CFG_ROLES, {}).values() + vhh = VirtualHousehold(with_solar) + + # Set up message dispatcher + dispatcher = PowersensorMessageDispatcher(hass, entry, vhh) + for network_info in entry.data.get(CFG_DEVICES, {}).values(): + await dispatcher.enqueue_plug_for_adding(network_info) + except Exception as err: + raise ConfigEntryNotReady(f"Unexpected error during setup: {err}") from err + + entry.runtime_data = { + RT_VHH: vhh, + RT_DISPATCHER: dispatcher, + RT_ZEROCONF: zeroconf_service, + } + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + _LOGGER.debug("Started unloading for %s", entry.entry_id) + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + if hasattr(entry, "runtime_data"): + if RT_DISPATCHER in entry.runtime_data: + await entry.runtime_data[RT_DISPATCHER].disconnect() + if RT_ZEROCONF in entry.runtime_data: + await entry.runtime_data[RT_ZEROCONF].stop() + + if entry.entry_id in hass.data[DOMAIN]: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old config entry.""" + _LOGGER.debug("Upgrading config from %s.%s", entry.version, entry.minor_version) + if entry.version > PowersensorConfigFlow.VERSION: + # Downgrade from future version + return False + + if entry.version == 1: + # Move device info into subkey + devices = {**entry.data} + new_data = {CFG_DEVICES: devices, CFG_ROLES: {}} + hass.config_entries.async_update_entry( + entry, data=new_data, version=2, minor_version=2 + ) + + _LOGGER.debug("Upgrading config to %s.%s", entry.version, entry.minor_version) + return True diff --git a/homeassistant/components/powersensor/config_flow.py b/homeassistant/components/powersensor/config_flow.py new file mode 100644 index 00000000000000..9f29d9dadfe4f4 --- /dev/null +++ b/homeassistant/components/powersensor/config_flow.py @@ -0,0 +1,212 @@ +"""Config flow for the integration.""" + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry, ConfigFlowResult +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.selector import selector +from homeassistant.helpers.service_info import zeroconf +from homeassistant.helpers.translation import async_get_cached_translations + +from .const import ( + CFG_DEVICES, + CFG_ROLES, + DEFAULT_PORT, + DOMAIN, + ROLE_APPLIANCE, + ROLE_HOUSENET, + ROLE_SOLAR, + ROLE_UPDATE_SIGNAL, + ROLE_WATER, + RT_DISPATCHER, +) + +_LOGGER = logging.getLogger(__name__) + + +def get_translated_sensor_name(hass: HomeAssistant, config_entry: ConfigEntry, mac: str) -> str|None: + """Helper to neatly format the translated name, for user input.""" + translations = async_get_cached_translations( + hass, hass.config.language, "device", config_entry.domain + ) + format_string = translations.get( + "component.powersensor.device.unknown_sensor.name", + "Powersensor Sensor (ID: {id})" + ) + return format_string.replace("{id}", mac) + + +class PowersensorConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 2 + MINOR_VERSION = 2 + + def __init__(self) -> None: + """Initialize the config flow.""" + + async def async_step_reconfigure( + self, user_input: dict | None = None + ) -> ConfigFlowResult: + """Handle reconfigure step. The primary use case is adding missing roles to sensors.""" + entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + if entry is None or not hasattr(entry, "runtime_data"): + return self.async_abort(reason="cannot_reconfigure") + + dispatcher = entry.runtime_data[RT_DISPATCHER] + if dispatcher is None: + return self.async_abort(reason="cannot_reconfigure") + + mac2name = {mac: get_translated_sensor_name(self.hass, entry, mac) for mac in dispatcher.sensors} + + unknown = "unknown" + if user_input is not None: + name2mac = {name: mac for mac, name in mac2name.items()} + for name, role in user_input.items(): + mac = name2mac.get(name) + if role == unknown: + role = None + _LOGGER.debug("Applying %s to %s", role, mac) + async_dispatcher_send(self.hass, ROLE_UPDATE_SIGNAL, mac, role) + return self.async_abort(reason="roles_applied") + + sensor_roles = {} + description_placeholders = {} + for sensor_mac in dispatcher.sensors: + role = entry.data.get(CFG_ROLES, {}).get(sensor_mac, unknown) + sel = selector( + { + "select": { + "options": [ + # Note: these strings are NOT subject to translation + ROLE_HOUSENET, + ROLE_SOLAR, + ROLE_WATER, + ROLE_APPLIANCE, + unknown, + ], + "mode": "dropdown", + } + } + ) + sensor_name = mac2name[sensor_mac] + sensor_roles[ + vol.Optional( + sensor_name, + description={"suggested_value": role, "name": sensor_name}, + ) + ] = sel + description_placeholders[sensor_name] = sensor_name + + description_placeholders["device_count"] = str(len(sensor_roles)) + description_placeholders["docs_url"] = ( + "https://dius.github.io/homeassistant-powersensor/data.html#virtual-household" + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema(sensor_roles), + description_placeholders={ + "device_count": str(len(sensor_roles)), + "docs_url": "https://dius.github.io/homeassistant-powersensor/data.html#virtual-household", + }, + ) + + async def _common_setup(self): + if DOMAIN not in self.hass.data: + self.hass.data[DOMAIN] = {} + + discovered_plugs_key = "discovered_plugs" + if discovered_plugs_key not in self.hass.data[DOMAIN]: + self.hass.data[DOMAIN][discovered_plugs_key] = {} + + # register a unique id for the single power sensor entry + await self.async_set_unique_id(DOMAIN) + + # abort now if configuration is on going in another thread (i.e. this thread isn't the first) + if self._async_current_entries() or self._async_in_progress(): + _LOGGER.warning("Aborting - found existing entry!") + return self.async_abort(reason="already_configured") + + display_name = "⚡ Powersensor 🔌\n" + self.context.update({"title_placeholders": {"name": display_name}}) + return None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + if result := await self._common_setup(): + return result + return await self.async_step_manual_confirm() + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + if result := await self._common_setup(): + return result + discovered_plugs_key = "discovered_plugs" + host = discovery_info.host + port = discovery_info.port or DEFAULT_PORT + properties = discovery_info.properties or {} + mac = None + if "id" in properties: + mac = properties["id"].strip() + else: + return self.async_abort(reason="firmware_not_compatible") + + display_name = f"🔌 Mac({mac})" + plug_data = { + "host": host, + "port": port, + "display_name": display_name, + "mac": mac, + "name": discovery_info.name, + } + + if mac in self.hass.data[DOMAIN][discovered_plugs_key]: + _LOGGER.debug("Mac found existing in data!") + else: + self.hass.data[DOMAIN][discovered_plugs_key][mac] = plug_data + + return await self.async_step_discovery_confirm() + + async def async_step_confirm( + self, step_id: str, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + "Confirm user wants to add the powersensor integration with the plugs stored in hass.data['powersensor']." + if user_input is not None: + _LOGGER.debug( + "Creating entry with discovered plugs: %s", + self.hass.data[DOMAIN]["discovered_plugs"], + ) + return self.async_create_entry( + title="Powersensor", + data={ + CFG_DEVICES: self.hass.data[DOMAIN]["discovered_plugs"], + CFG_ROLES: {}, + }, + ) + return self.async_show_form(step_id=step_id) + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + "Confirm user wants to add the powersensor integration with the plugs discovered." + return await self.async_step_confirm( + step_id="discovery_confirm", user_input=user_input + ) + + async def async_step_manual_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + "Confirm user wants to add the powersensor integration with manual configuration (typically no plugs available)." + return await self.async_step_confirm( + step_id="manual_confirm", user_input=user_input + ) diff --git a/homeassistant/components/powersensor/const.py b/homeassistant/components/powersensor/const.py new file mode 100644 index 00000000000000..efe86b6bd80832 --- /dev/null +++ b/homeassistant/components/powersensor/const.py @@ -0,0 +1,35 @@ +"""Constants for the Powersensor integration.""" + +DOMAIN = "powersensor" +DEFAULT_NAME = "Powersensor" +DEFAULT_PORT = 49476 + +# Internal signals +CREATE_PLUG_SIGNAL = f"{DOMAIN}_create_plug" +CREATE_SENSOR_SIGNAL = f"{DOMAIN}_create_sensor" +DATA_UPDATE_SIGNAL_FMT_MAC_EVENT = f"{DOMAIN}_data_update_%s_%s" +ROLE_UPDATE_SIGNAL = f"{DOMAIN}_update_role" +PLUG_ADDED_TO_HA_SIGNAL = f"{DOMAIN}_plug_added_to_homeassistant" +SENSOR_ADDED_TO_HA_SIGNAL = f"{DOMAIN}_sensor_added_to_homeassistant" +UPDATE_VHH_SIGNAL = f"{DOMAIN}_update_vhh" +ZEROCONF_ADD_PLUG_SIGNAL = f"{DOMAIN}_zeroconf_add_plug" +ZEROCONF_REMOVE_PLUG_SIGNAL = f"{DOMAIN}_zeroconf_remove_plug" +ZEROCONF_UPDATE_PLUG_SIGNAL = f"{DOMAIN}_zeroconf_update_plug" + +# Config entry keys +CFG_DEVICES = "devices" +CFG_ROLES = "roles" + +# Role names (fixed, as-received from plug API) +ROLE_APPLIANCE = "appliance" +ROLE_HOUSENET = "house-net" +ROLE_SOLAR = "solar" +ROLE_WATER = "water" + +# runtime_data keys +RT_DISPATCHER = "dispatcher" +RT_VHH = "vhh" +RT_VHH_LOCK = "vhh_update_lock" +RT_VHH_MAINS_ADDED = "vhh_main_added" +RT_VHH_SOLAR_ADDED = "vhh_solar_added" +RT_ZEROCONF = "zeroconf" diff --git a/homeassistant/components/powersensor/manifest.json b/homeassistant/components/powersensor/manifest.json new file mode 100644 index 00000000000000..437b10a4b00c1d --- /dev/null +++ b/homeassistant/components/powersensor/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "powersensor", + "name": "Powersensor", + "codeowners": [ + "@bookman-dius", + "@jmattsson" + ], + "config_flow": true, + "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/powersensor", + "integration_type": "hub", + "iot_class": "local_push", + "quality_scale": "bronze", + "requirements": ["powersensor-local==2.1.1"], + "single_config_entry": true, + "zeroconf": ["_powersensor._udp.local."] +} diff --git a/homeassistant/components/powersensor/plug_measurements.py b/homeassistant/components/powersensor/plug_measurements.py new file mode 100644 index 00000000000000..2bbf3943f58f0d --- /dev/null +++ b/homeassistant/components/powersensor/plug_measurements.py @@ -0,0 +1,15 @@ +"""Enum determining what measurements a Powersensor plug can report.""" + +from enum import Enum + + +class PlugMeasurements(Enum): + """Enum to keep track of what measurements plugs can report.""" + + WATTS = 1 + VOLTAGE = 2 + APPARENT_CURRENT = 3 + ACTIVE_CURRENT = 4 + REACTIVE_CURRENT = 5 + SUMMATION_ENERGY = 6 + ROLE = 7 diff --git a/homeassistant/components/powersensor/powersensor_discovery_service.py b/homeassistant/components/powersensor/powersensor_discovery_service.py new file mode 100644 index 00000000000000..6f3740a7a2230a --- /dev/null +++ b/homeassistant/components/powersensor/powersensor_discovery_service.py @@ -0,0 +1,200 @@ +"""Utilities to support zeroconf discovery of new plugs on the network.""" + +import asyncio +from contextlib import suppress +import logging + +from zeroconf import BadTypeInNameException, ServiceBrowser, ServiceListener, Zeroconf +from zeroconf.asyncio import AsyncServiceInfo + +import homeassistant.components.zeroconf +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.loader import bind_hass + +from .const import ( + ZEROCONF_ADD_PLUG_SIGNAL, + ZEROCONF_REMOVE_PLUG_SIGNAL, + ZEROCONF_UPDATE_PLUG_SIGNAL, +) + +_LOGGER = logging.getLogger(__name__) + + +class PowersensorServiceListener(ServiceListener): + """A zeroconf service listener that handles the discovery of plugs and signals the dispatcher.""" + + def __init__(self, hass: HomeAssistant, debounce_timeout: float = 60) -> None: + """Initialize the listener, set up various buffers to hold info.""" + self._hass = hass + self._plugs: dict[str, dict] = {} + self._discoveries: dict[str, AsyncServiceInfo] = {} + self._pending_removals: dict[str, concurrent.futures.Future] = {} + self._debounce_seconds = debounce_timeout + + def add_service(self, zc, type_, name): + """Handle zeroconf messages for adding new devices.""" + self.cancel_any_pending_removal(name, "request to add") + info = self.__add_plug(zc, type_, name) + if info: + asyncio.run_coroutine_threadsafe( + self._async_service_add(self._plugs[name]), self._hass.loop + ) + + async def _async_service_add(self, *args): + """Send add signal to dispatcher.""" + self.dispatch(ZEROCONF_ADD_PLUG_SIGNAL, *args) + + async def _async_delayed_remove(self, name): + """Actually process the removal after delay.""" + try: + await asyncio.sleep(self._debounce_seconds) + _LOGGER.info( + "Request to remove service %s still pending after timeout. Processing remove request... ", + name, + ) + if name in self._plugs: + data = self._plugs[name].copy() + del self._plugs[name] + else: + data = None + asyncio.run_coroutine_threadsafe( + self._async_service_remove(name, data), self._hass.loop + ) + except asyncio.CancelledError: + # Task was cancelled because service came back + _LOGGER.info( + "Request to remove service %s was canceled by request to update or add plug. ", + name, + ) + raise + finally: + # Either way were done with this task + self._pending_removals.pop(name, None) + + def remove_service(self, zc, type_, name): + """Handle zeroconf messages for removal of devices.""" + if name in self._pending_removals: + # removal for this service is already pending + return + + _LOGGER.info("Scheduling removal for %s", name) + self._pending_removals[name] = asyncio.run_coroutine_threadsafe( + self._async_delayed_remove(name), self._hass.loop + ) + + async def _async_service_remove(self, *args): + """Send remove signal to dispatcher.""" + self.dispatch(ZEROCONF_REMOVE_PLUG_SIGNAL, *args) + + def update_service(self, zc, type_, name): + """Handle zeroconf messages for updating device info.""" + self.cancel_any_pending_removal(name, "request to update") + info = self.__add_plug(zc, type_, name) + if info: + asyncio.run_coroutine_threadsafe( + self._async_service_update(self._plugs[name]), self._hass.loop + ) + + async def _async_service_update(self, *args): + """Send update signal to dispatcher.""" + # remove from pending tasks if update received + self.dispatch(ZEROCONF_UPDATE_PLUG_SIGNAL, *args) + + async def _async_get_service_info(self, zc, type_, name): + try: + info = await zc.async_get_service_info(type_, name, timeout=3000) + self._discoveries[name] = info + except ( + TimeoutError, + OSError, + BadTypeInNameException, + NotImplementedError, + ) as err: # expected possible exceptions + _LOGGER.error("Error retrieving info for %s: %s", name, err) + + def __add_plug(self, zc, type_, name): + info = zc.get_service_info(type_, name) + + if info: + self._plugs[name] = { + "type": type_, + "name": name, + "addresses": [ + ".".join(str(b) for b in addr) for addr in info.addresses + ], + "port": info.port, + "server": info.server, + "properties": info.properties, + } + return info + + def cancel_any_pending_removal(self, name, source): + """Cancel pending removal and don't send to dispatcher.""" + task = self._pending_removals.pop(name, None) + if task: + task.cancel() + _LOGGER.info("Cancelled pending removal for %s by %s. ", name, source) + + @callback + @bind_hass + def dispatch(self, signal_name, *args): + """Send signal to dispatcher.""" + async_dispatcher_send(self._hass, signal_name, *args) + + +class PowersensorDiscoveryService: + """A zeroconf service that handles the discovery of plugs.""" + + def __init__( + self, hass: HomeAssistant, service_type: str = "_powersensor._udp.local." + ) -> None: + """Constructor for zeroconf service that handles the discovery of plugs.""" + self._hass = hass + self.service_type = service_type + + self.zc: Zeroconf | None = None + self.listener: PowersensorServiceListener | None = None + self.browser: ServiceBrowser | None = None + self.running = False + self._task: asyncio.Task | None = None + + async def start(self): + """Start the mDNS discovery service.""" + if self.running: + return + + self.running = True + self.zc = await homeassistant.components.zeroconf.async_get_instance(self._hass) + self.listener = PowersensorServiceListener(self._hass) + + # Create browser + self.browser = ServiceBrowser(self.zc, self.service_type, self.listener) + + # Start the background task + self._task = asyncio.create_task(self._run()) + + async def _run(self): + """Background task that keeps the service alive.""" + with suppress(asyncio.CancelledError): + while self.running: + await asyncio.sleep(1) + + async def stop(self): + """Stop the mDNS discovery service.""" + self.running = False + + if self._task: + self._task.cancel() + with suppress(asyncio.CancelledError): + await self._task + + if self.browser is not None: + self.browser.cancel() + + if self.zc: + # self.zc.close() + self.zc = None + + self.browser = None + self.listener = None diff --git a/homeassistant/components/powersensor/powersensor_entity.py b/homeassistant/components/powersensor/powersensor_entity.py new file mode 100644 index 00000000000000..7611041e9b7bf0 --- /dev/null +++ b/homeassistant/components/powersensor/powersensor_entity.py @@ -0,0 +1,165 @@ +"""A generic abstract class which both PowersensorPlugs and PowersensorSensors subclass to share common methods.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import Generic, TypeVar + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util.dt import utcnow + +from homeassistant.components.powersensor.const import DATA_UPDATE_SIGNAL_FMT_MAC_EVENT, DOMAIN, ROLE_UPDATE_SIGNAL +from homeassistant.components.powersensor.plug_measurements import PlugMeasurements +from homeassistant.components.powersensor.sensor_measurements import SensorMeasurements + +_LOGGER = logging.getLogger(__name__) + +MeasurementType = TypeVar("MeasurementType", SensorMeasurements, PlugMeasurements) + + +@dataclass(frozen=True, kw_only=True) +class PowersensorSensorEntityDescription(SensorEntityDescription): + """Powersensor Sensor Entity Description.""" + + conversion_function: Callable | None = None + event: str | None = None + message_key: str | None = None + + +class PowersensorEntity(SensorEntity, Generic[MeasurementType]): + """Base class for all Powersensor entities.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__( + self, + hass: HomeAssistant, + config_entry_id: str, + mac: str, + role: str, + input_config: dict[MeasurementType, PowersensorSensorEntityDescription], + measurement_type: MeasurementType, + timeout_seconds: int = 60, + ) -> None: + """Initialize the sensor.""" + self._role = role + self._has_recently_received_update_message = False + self._attr_native_value = None + self._hass = hass + self._config_entry_id = config_entry_id + self._mac = mac + self._model = "PowersensorDevice" + self._device_name = f"Powersensor Device (ID: {self._mac})" + self._measurement_name: str | None = None + self._remove_unavailability_tracker = None + self._timeout = timedelta(seconds=timeout_seconds) # Adjust as needed + + self.measurement_type: MeasurementType = measurement_type + self.entity_description = input_config[measurement_type] + config: PowersensorSensorEntityDescription = input_config[measurement_type] + self.entity_description = config + + self._attr_unique_id = f"{mac}_{measurement_type.name}" + + self._signal = DATA_UPDATE_SIGNAL_FMT_MAC_EVENT % (mac, config.event) + self._message_key = config.message_key + self._message_callback = config.conversion_function + + + @property + def device_info(self) -> DeviceInfo: + """Abstract property to for returning DeviceInfo.""" + raise NotImplementedError + + @property + def available(self) -> bool: + """Does data exist for this sensor type.""" + return self._has_recently_received_update_message + + def _schedule_unavailable(self): + """Schedule entity to become unavailable.""" + if self._remove_unavailability_tracker: + self._remove_unavailability_tracker() + + self._remove_unavailability_tracker = async_track_point_in_utc_time( + self._hass, self._async_make_unavailable, utcnow() + self._timeout + ) + + async def _async_make_unavailable(self, _now): + """Mark entity as unavailable.""" + self._has_recently_received_update_message = False + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Subscribe to messages when added to home assistant.""" + self._has_recently_received_update_message = False + self.async_on_remove( + async_dispatcher_connect(self._hass, self._signal, self._handle_update) + ) + self.async_on_remove( + async_dispatcher_connect( + self._hass, ROLE_UPDATE_SIGNAL, self._handle_role_update + ) + ) + + async def async_will_remove_from_hass(self): + """Clean up.""" + if self._remove_unavailability_tracker: + self._remove_unavailability_tracker() + + def _rename_based_on_role(self): + return False + + @callback + def _handle_role_update(self, mac: str, role: str | None) -> None: + if self._mac != mac or self._role == role: + return + + self._role = role + was_updated = self._rename_based_on_role() + + if was_updated: + device_registry = dr.async_get(self._hass) + device = device_registry.async_get_device(identifiers={(DOMAIN, self._mac)}) + info = self.device_info + if device is not None and info is not None: + # The device registry provides no way of just updating the + # translation_key via dr.async_update_device(), only the + # name. The only way to properly apply the translation to + # the name is through async_get_or_create(), which also + # does apply the update, but requires knowing the config + # entry id. Hoops and roundabouts. + device_registry.async_get_or_create( + config_entry_id = self._config_entry_id, + identifiers = device.identifiers, + translation_key = info["translation_key"], + translation_placeholders = { "id": self._mac }, + ) + + self.async_write_ha_state() + + @callback + def _handle_update(self, event, message): + """Handle pushed data.""" + + # event is not presently used, but is passed to maintain flexibility for future development + + self._has_recently_received_update_message = True + + if self._message_key in message: + if self._message_callback: + self._attr_native_value = self._message_callback( + message[self._message_key] + ) + else: + self._attr_native_value = message[self._message_key] + self._schedule_unavailable() + + self.async_write_ha_state() diff --git a/homeassistant/components/powersensor/powersensor_household_entity.py b/homeassistant/components/powersensor/powersensor_household_entity.py new file mode 100644 index 00000000000000..69a5148d2d1884 --- /dev/null +++ b/homeassistant/components/powersensor/powersensor_household_entity.py @@ -0,0 +1,197 @@ +"""Wrapper around powersensor_local.VirtualHousehold for smooth interface with Homeassistant energy view.""" + +from collections.abc import Callable +from dataclasses import dataclass +from enum import Enum + +from powersensor_local import VirtualHousehold + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfEnergy, UnitOfPower +from homeassistant.helpers.device_registry import DeviceInfo + +from homeassistant.components.powersensor.const import DOMAIN + + +class HouseholdMeasurements(Enum): + """Measurements for Household Entity.""" + + POWER_HOME_USE = 1 + POWER_FROM_GRID = 2 + POWER_TO_GRID = 3 + POWER_SOLAR_GENERATION = 4 + ENERGY_HOME_USE = 5 + ENERGY_FROM_GRID = 6 + ENERGY_TO_GRID = 7 + ENERGY_SOLAR_GENERATION = 8 + + +ConsumptionMeasurements = [ + HouseholdMeasurements.POWER_HOME_USE, + HouseholdMeasurements.POWER_FROM_GRID, + HouseholdMeasurements.ENERGY_HOME_USE, + HouseholdMeasurements.ENERGY_FROM_GRID, +] +ProductionMeasurements = [ + HouseholdMeasurements.POWER_TO_GRID, + HouseholdMeasurements.POWER_SOLAR_GENERATION, + HouseholdMeasurements.ENERGY_TO_GRID, + HouseholdMeasurements.ENERGY_SOLAR_GENERATION, +] + + +@dataclass(frozen=True, kw_only=True) +class PowersensorVirtualHouseholdSensorEntityDescription(SensorEntityDescription): + """Powersensor Virtual Household Sensor Entity Description.""" + + formatter: Callable + event: str + + +def fmt_int(f): + """Wrapper to format integers appropriately.""" + return int(f) + + +def fmt_ws_to_kwh(f): + """Wrapper convert a watt-seconds string to kilowatt-hours float.""" + return float(f) / 3600000 + + +class PowersensorHouseholdEntity(SensorEntity): + """Powersensor Virtual Household entity.""" + + _attr_available = True + _attr_has_entity_name = True + _attr_should_poll = False + + _ENTITY_CONFIGS = { + HouseholdMeasurements.POWER_HOME_USE: PowersensorVirtualHouseholdSensorEntityDescription( + key="Power - Home use", + translation_key="power_home_use", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + formatter=fmt_int, + event="home_usage", + ), + HouseholdMeasurements.POWER_FROM_GRID: PowersensorVirtualHouseholdSensorEntityDescription( + key="Power - From grid", + translation_key="power_from_grid", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + formatter=fmt_int, + event="from_grid", + ), + HouseholdMeasurements.POWER_TO_GRID: PowersensorVirtualHouseholdSensorEntityDescription( + key="Power - To grid", + translation_key="power_to_grid", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + formatter=fmt_int, + event="to_grid", + ), + HouseholdMeasurements.POWER_SOLAR_GENERATION: PowersensorVirtualHouseholdSensorEntityDescription( + key="Power - Solar generation", + translation_key="power_solar_generation", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + formatter=fmt_int, + event="solar_generation", + ), + HouseholdMeasurements.ENERGY_HOME_USE: PowersensorVirtualHouseholdSensorEntityDescription( + key="Energy - Home usage", + translation_key="energy_home_use", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=3, + formatter=fmt_ws_to_kwh, + event="home_usage_summation", + ), + HouseholdMeasurements.ENERGY_FROM_GRID: PowersensorVirtualHouseholdSensorEntityDescription( + key="Energy - From grid", + translation_key="energy_from_grid", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + formatter=fmt_ws_to_kwh, + suggested_display_precision=3, + event="from_grid_summation", + ), + HouseholdMeasurements.ENERGY_TO_GRID: PowersensorVirtualHouseholdSensorEntityDescription( + key="Energy - To grid", + translation_key="energy_to_grid", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + formatter=fmt_ws_to_kwh, + suggested_display_precision=3, + event="to_grid_summation", + ), + HouseholdMeasurements.ENERGY_SOLAR_GENERATION: PowersensorVirtualHouseholdSensorEntityDescription( + key="Energy - Solar generation", + translation_key="energy_solar_generation", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + formatter=fmt_ws_to_kwh, + suggested_display_precision=3, + event="solar_generation_summation", + ), + } + + def __init__( + self, vhh: VirtualHousehold, measurement_type: HouseholdMeasurements + ) -> None: + """Initialize the entity.""" + self._vhh = vhh + self._config = self._ENTITY_CONFIGS[measurement_type] + + self._attr_unique_id = f"vhh_{self._config.event}" + + self.entity_description = self._config + + @property + def device_info(self) -> DeviceInfo: + """DeviceInfo for Powersensor Virtual Household Entity. Includes mac, name and model.""" + return { + "identifiers": {(DOMAIN, "vhh")}, + "manufacturer": "Powersensor", + "model": "Virtual", + "translation_key": "virtual_household_view", + } + + async def async_added_to_hass(self): + """When added to Homeassistant, Virtual Household needs to subscribe to the update events.""" + self._vhh.subscribe(self._config.event, self._on_event) + + async def async_will_remove_from_hass(self): + """When removed to Homeassistant, Virtual Household needs to unsubscribe to the update events.""" + self._vhh.unsubscribe(self._config.event, self._on_event) + + async def _on_event(self, _, msg): + val = None + if self._config.native_unit_of_measurement == UnitOfPower.WATT: + key = "watts" + if key in msg: + val = msg[key] + elif self._config.native_unit_of_measurement == UnitOfEnergy.KILO_WATT_HOUR: + key = "summation_joules" + if key in msg: + val = msg[key] + if val is not None: + self._attr_native_value = self._config.formatter(val) + self.async_write_ha_state() diff --git a/homeassistant/components/powersensor/powersensor_message_dispatcher.py b/homeassistant/components/powersensor/powersensor_message_dispatcher.py new file mode 100644 index 00000000000000..6058a24c104a31 --- /dev/null +++ b/homeassistant/components/powersensor/powersensor_message_dispatcher.py @@ -0,0 +1,425 @@ +"""PowersensorMessageDispatcher is the main coordinator of messages. + +The classes and utilities here mediate Powersensor PlugApi messages and updates/creation of Homeassistant Entities. +""" + +import asyncio +from contextlib import suppress +import datetime +import logging +from typing import Any + +from powersensor_local import PlugApi, VirtualHousehold + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) + +from .const import ( + # Used config entry fields + CFG_ROLES, + # Used signals + CREATE_PLUG_SIGNAL, + CREATE_SENSOR_SIGNAL, + DATA_UPDATE_SIGNAL_FMT_MAC_EVENT, + PLUG_ADDED_TO_HA_SIGNAL, + ROLE_UPDATE_SIGNAL, + SENSOR_ADDED_TO_HA_SIGNAL, + ZEROCONF_ADD_PLUG_SIGNAL, + ZEROCONF_REMOVE_PLUG_SIGNAL, + ZEROCONF_UPDATE_PLUG_SIGNAL, +) + +_LOGGER = logging.getLogger(__name__) +UNKNOWN = "unknown" + + +async def _handle_exception(event: str, exc: BaseException): + """Log errors when PlugApi throws an exception.""" + _LOGGER.error( + "On event %s Plug connection reported exception: %s", + event, + exc, + exc_info=exc, + ) + + +def _filter_unknown(role: str): + """Filters out roles matching "unknown" by returning None instead.""" + return None if role == UNKNOWN else role + + +class PowersensorMessageDispatcher: + """Message Dispatcher which sends and receives signals around HA entities.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + vhh: VirtualHousehold, + debounce_timeout: float = 60, + ) -> None: + """Constructor for message dispatcher. + + This class mediates the push messages from the plug api and controls updates for HA entities. + """ + self._hass = hass + self._entry = entry + self._vhh = vhh + self.plugs: dict[str, PlugApi] = {} + self._known_plugs: set[str] = set() + self._known_plug_names: dict[str, str] = {} + self.sensors: dict[str, str] = {} + self.on_start_sensor_queue: dict[str, Any] = {} + self._pending_removals: dict[str, asyncio.Task] = {} + self._debounce_seconds = debounce_timeout + self.has_solar = False + self._solar_request_limit = datetime.timedelta(seconds=10) + self._unsubscribe_from_signals = [ + async_dispatcher_connect( + self._hass, ZEROCONF_ADD_PLUG_SIGNAL, self._plug_added + ), + async_dispatcher_connect( + self._hass, ZEROCONF_UPDATE_PLUG_SIGNAL, self._plug_updated + ), + async_dispatcher_connect( + self._hass, ZEROCONF_REMOVE_PLUG_SIGNAL, self._schedule_plug_removal + ), + async_dispatcher_connect( + self._hass, + PLUG_ADDED_TO_HA_SIGNAL, + self._acknowledge_plug_added_to_homeassistant, + ), + async_dispatcher_connect( + self._hass, + SENSOR_ADDED_TO_HA_SIGNAL, + self._acknowledge_sensor_added_to_homeassistant, + ), + ] + + self._monitor_add_plug_queue = None + self._stop_task = False + self._plug_added_queue: set = set() + self._safe_to_process_plug_queue = False + + async def enqueue_plug_for_adding(self, network_info: dict): + """On receiving zeroconf data this info is added to processing buffer to await creation of entity and api.""" + _LOGGER.debug("Adding to plug processing queue: %s", network_info) + self._plug_added_queue.add( + ( + network_info["mac"], + network_info["host"], + network_info["port"], + network_info["name"], + ) + ) + + async def process_plug_queue(self): + """Start the background task if not already running.""" + self._safe_to_process_plug_queue = True + if self._monitor_add_plug_queue is None or self._monitor_add_plug_queue.done(): + self._stop_task = False + self._monitor_add_plug_queue = self._hass.async_create_background_task( + self._monitor_plug_queue(), name="plug_queue_monitor" + ) + _LOGGER.debug("Background task started") + + def _plug_has_been_seen(self, mac_address, name) -> bool: + return ( + mac_address in self.plugs + or mac_address in self._known_plugs + or name in self._known_plug_names + ) + + async def _monitor_plug_queue(self): + """The actual background task loop.""" + try: + while not self._stop_task and self._plug_added_queue: + queue_snapshot = self._plug_added_queue.copy() + for mac_address, host, port, name in queue_snapshot: + # @todo: maybe better to query the entity registry? + if not self._plug_has_been_seen(mac_address, name): + async_dispatcher_send( + self._hass, + CREATE_PLUG_SIGNAL, + mac_address, + host, + port, + name, + ) + elif ( + mac_address in self._known_plugs + and mac_address not in self.plugs + ): + _LOGGER.info( + "Plug with mac %s is known, but API is missing." + "Reconnecting without requesting entity creation... ", + mac_address, + ) + self._create_api(mac_address, host, port, name) + else: + _LOGGER.debug( + "Plug: %s has already been created as an entity in Home Assistant." + " Skipping and flushing from queue. ", + mac_address, + ) + self._plug_added_queue.remove( + (mac_address, host, port, name) + ) + + await asyncio.sleep(5) + _LOGGER.debug("Plug queue has been processed!") + + except asyncio.CancelledError: + _LOGGER.debug("Plug queue processing cancelled") + raise + except ( + TimeoutError, + OSError, + NotImplementedError, + ) as e: # just trying to add a little crash free safety, if not catch all errors + _LOGGER.error("Error in Plug queue processing task: %s", e) + finally: + self._monitor_add_plug_queue = None + + def _get_role_info(self, message): + """Retrieve the effective role and persisted role for this message.""" + # Filter in case older version stuck an "unknown" in there + persisted_role = _filter_unknown( + self._entry.data.get(CFG_ROLES, {}).get(message["mac"], None) + ) + # The sensor *does* send "unknown", not null/None, so filter it + role = _filter_unknown(message.get("role", None)) + return role, persisted_role + + async def stop_processing_plug_queue(self): + """Stop the background task.""" + self._stop_task = True + if self._monitor_add_plug_queue and not self._monitor_add_plug_queue.done(): + self._monitor_add_plug_queue.cancel() + with suppress(asyncio.CancelledError): + await self._monitor_add_plug_queue + _LOGGER.debug("Background task stopped") + self._monitor_add_plug_queue = None + + async def stop_pending_removal_tasks(self): + """Stop the background removal tasks.""" + # create a temporary copy to avoid concurrency problems + task_list = list(self._pending_removals.values()) + for task in task_list: + if task and not task.done(): + task.cancel() + with suppress(asyncio.CancelledError): + await task + + _LOGGER.debug("Background removal task stopped") + self._pending_removals = {} + + def _create_api(self, mac_address, ip, port, name): + _LOGGER.info("Creating API for mac=%s, ip=%s, port=%s", mac_address, ip, port) + api = PlugApi(mac=mac_address, ip=ip, port=port) + self.plugs[mac_address] = api + self._known_plugs.add(mac_address) + self._known_plug_names[name] = mac_address + known_evs = [ + "average_flow", + "average_power", + "average_power_components", + "battery_level", + "radio_signal_quality", + "summation_energy", + "summation_volume", + #'uncalibrated_instant_reading', + ] + + for ev in known_evs: + api.subscribe(ev, self.handle_message) + api.subscribe("now_relaying_for", self.handle_relaying_for) + api.subscribe("exception", _handle_exception) + api.connect() + + async def cancel_any_pending_removal(self, mac, source): + """Cancel removal of a plug that has been scheduled.""" + task = self._pending_removals.pop(mac, None) + if task: + task.cancel() + with suppress(asyncio.CancelledError): + await task + _LOGGER.debug("Cancelled pending removal for %s by %s. ", mac, source) + + async def handle_relaying_for(self, event: str, message: dict): + """Handle a potentially new sensor being reported.""" + mac = message.get("mac") + device_type = message.get("device_type") + if mac is None or device_type != "sensor": + _LOGGER.warning( + 'Ignoring relayed device with MAC "%s" and type %s', mac, device_type + ) + return + + role, persisted_role = self._get_role_info(message) + _LOGGER.debug("Relayed sensor %s with role %s found", mac, role) + + if mac not in self.sensors: + _LOGGER.debug("Reporting new sensor %s with role %s", mac, role) + self.on_start_sensor_queue[mac] = role + async_dispatcher_send(self._hass, CREATE_SENSOR_SIGNAL, mac, role) + + # We only apply a known persisted role, so we don't clobber a sensor's + # actual knowledge. + if persisted_role is not None and role != persisted_role: + _LOGGER.debug( + "Restoring role for %s from %s to %s", mac, role, persisted_role + ) + async_dispatcher_send(self._hass, ROLE_UPDATE_SIGNAL, mac, persisted_role) + + async def handle_message(self, event: str, message: dict): + """Callback for handling messages from PlugApi. + + This includes but is not limited to: updating sensor data, device roles, canceling removal if data is still + flowing from a device but zeroconf scheduled removal and signaling for creation of new Homeassistant entities. + """ + mac = message["mac"] + role, persisted_role = self._get_role_info(message) + + # Apply persisted role information if necessary + message["role"] = persisted_role if role is None else role + + # Unknown roles from the sensor should not be allowed to overwrite + # any persisted roles + if role is not None and role != persisted_role: + self.sensors[mac] = role + async_dispatcher_send(self._hass, ROLE_UPDATE_SIGNAL, mac, role) + + await self.cancel_any_pending_removal(mac, "new message received from plug") + + # Feed the household calculations + if event == "average_power": + await self._vhh.process_average_power_event(message) + elif event == "summation_energy": + await self._vhh.process_summation_event(message) + + async_dispatcher_send( + self._hass, DATA_UPDATE_SIGNAL_FMT_MAC_EVENT % (mac, event), event, message + ) + + # Synthesise a role type message for the role diagnostic entity + async_dispatcher_send( + self._hass, + DATA_UPDATE_SIGNAL_FMT_MAC_EVENT % (mac, "role"), + "role", + {"role": role}, + ) + + async def disconnect(self): + """Handle graceful disconnection of PlugApi objects.""" + for _ in range(len(self.plugs)): + _, api = self.plugs.popitem() + await api.disconnect() + for unsubscribe in self._unsubscribe_from_signals: + if unsubscribe is not None: + unsubscribe() + + await self.stop_processing_plug_queue() + await self.stop_pending_removal_tasks() + + @callback + def _acknowledge_sensor_added_to_homeassistant(self, mac, role): + self.sensors[mac] = role + + async def _acknowledge_plug_added_to_homeassistant( + self, mac_address, host, port, name + ): + self._create_api(mac_address, host, port, name) + self._plug_added_queue.remove((mac_address, host, port, name)) + + async def _plug_added(self, info): + _LOGGER.debug(" Request to add plug received: %s", info) + network_info = {} + mac = info["properties"][b"id"].decode("utf-8") + network_info["mac"] = mac + await self.cancel_any_pending_removal(mac, "request to add plug") + network_info["host"] = info["addresses"][0] + network_info["port"] = info["port"] + network_info["name"] = info["name"] + + if self._safe_to_process_plug_queue: + await self.enqueue_plug_for_adding(network_info) + await self.process_plug_queue() + else: + await self.enqueue_plug_for_adding(network_info) + + async def _plug_updated(self, info) -> None: + _LOGGER.debug("Request to update plug received: %s", info) + mac = info["properties"][b"id"].decode("utf-8") + await self.cancel_any_pending_removal(mac, "request to update plug") + host = info["addresses"][0] + port = info["port"] + name = info["name"] + + if mac in self.plugs: + current_api: PlugApi = self.plugs[mac] + if current_api.ip_address == host and current_api.port == port: + _LOGGER.debug( + "Request to update plug with mac %s does not alter ip from existing API." + "IP still %s and port is %s. Skipping update... ", + mac, + host, + port, + ) + return + await current_api.disconnect() + + if mac in self._known_plugs: + self._create_api(mac, host, port, name) + else: + network_info = {"mac": mac, "host": host, "port": port, "name": name} + await self.enqueue_plug_for_adding(network_info) + await self.process_plug_queue() + + async def _schedule_plug_removal(self, name, info): + _LOGGER.debug("Request to delete plug received: %s", info) + if name in self._known_plug_names: + mac = self._known_plug_names[name] + if mac in self.plugs: + if mac in self._pending_removals: + # removal for this service is already pending + return + + _LOGGER.debug("Scheduling removal for %s", name) + self._pending_removals[mac] = self._hass.async_create_background_task( + self._delayed_plug_remove(name, mac), + name=f"Removal-Task-For-{name}", + ) + else: + _LOGGER.warning( + "Received request to delete api for gateway with name [%s], but this name" + "is not associated with an existing PlugAPI. Ignoring... ", + name, + ) + + async def _delayed_plug_remove(self, name, mac): + """Actually process the removal after delay.""" + try: + await asyncio.sleep(self._debounce_seconds) + _LOGGER.debug( + "Request to remove plug %s still pending after timeout. Processing remove request... ", + mac, + ) + await self.plugs[mac].disconnect() + del self.plugs[mac] + del self._known_plug_names[name] + _LOGGER.info("API for plug %s disconnected and removed. ", mac) + except asyncio.CancelledError: + # Task was canceled because service came back + _LOGGER.debug( + "Request to remove plug %s was cancelled by request to update, add plug or new message. ", + mac, + ) + raise + finally: + # Either way were done with this task + self._pending_removals.pop(mac, None) diff --git a/homeassistant/components/powersensor/powersensor_plug_entity.py b/homeassistant/components/powersensor/powersensor_plug_entity.py new file mode 100644 index 00000000000000..c0512fec6bdad3 --- /dev/null +++ b/homeassistant/components/powersensor/powersensor_plug_entity.py @@ -0,0 +1,129 @@ +"""Class for creation of Homeassistant Entities related to all Powersensor Plug measurements.""" + +import logging + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import ( + EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo + +from homeassistant.components.powersensor.const import DOMAIN +from homeassistant.components.powersensor.plug_measurements import PlugMeasurements +from homeassistant.components.powersensor.powersensor_entity import PowersensorEntity, PowersensorSensorEntityDescription + +_LOGGER = logging.getLogger(__name__) + + +_config: dict[PlugMeasurements, PowersensorSensorEntityDescription] = { + PlugMeasurements.WATTS: PowersensorSensorEntityDescription( + key="Power", + translation_key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=1, + event="average_power", + message_key="watts", + ), + PlugMeasurements.VOLTAGE: PowersensorSensorEntityDescription( + key="Volts", + translation_key="volts", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=2, + event="average_power_components", + message_key="volts", + entity_registry_visible_default=False, + ), + PlugMeasurements.APPARENT_CURRENT: PowersensorSensorEntityDescription( + key="Apparent Current", + translation_key="apparent_current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + event="average_power_components", + message_key="apparent_current", + entity_registry_visible_default=False, + ), + PlugMeasurements.ACTIVE_CURRENT: PowersensorSensorEntityDescription( + key="Active Current", + translation_key="active_current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + event="average_power_components", + message_key="active_current", + entity_registry_visible_default=False, + ), + PlugMeasurements.REACTIVE_CURRENT: PowersensorSensorEntityDescription( + key="Reactive Current", + translation_key="reactive_current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + event="average_power_components", + message_key="reactive_current", + entity_registry_visible_default=False, + ), + PlugMeasurements.SUMMATION_ENERGY: PowersensorSensorEntityDescription( + key="Total Energy", + translation_key="total_energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=2, + event="summation_energy", + message_key="summation_joules", + conversion_function=lambda v: v / 3600000.0, + ), + PlugMeasurements.ROLE: PowersensorSensorEntityDescription( + key="Device Role", + translation_key="device_role", + entity_category=EntityCategory.DIAGNOSTIC, + event="role", + message_key="role", + ), +} + + +class PowersensorPlugEntity(PowersensorEntity): + """Powersensor Plug Class--designed to handle all measurements of the plug--perhaps less expressive.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__( + self, + hass: HomeAssistant, + entry_id: str, + mac_address: str, + role: str, + measurement_type: PlugMeasurements, + ) -> None: + """Initialize the sensor.""" + super().__init__(hass, entry_id, mac_address, role, _config, measurement_type) + self.measurement_type = measurement_type + config = _config[measurement_type] + + @property + def device_info(self) -> DeviceInfo: + """DeviceInfo for PowersensorPlug. Includes mac address, name and model.""" + return { + "identifiers": {(DOMAIN, self._mac)}, + "manufacturer": "Powersensor", + "model": "PowersensorPlug", + "translation_key": "plug", + "translation_placeholders": { + "id": self._mac, + }, + } diff --git a/homeassistant/components/powersensor/powersensor_sensor_entity.py b/homeassistant/components/powersensor/powersensor_sensor_entity.py new file mode 100644 index 00000000000000..165ec8477cec51 --- /dev/null +++ b/homeassistant/components/powersensor/powersensor_sensor_entity.py @@ -0,0 +1,127 @@ +"""Class for creation of Homeassistant Entities related to all Powersensor Sensor measurements.""" + +import logging + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS, + EntityCategory, + UnitOfEnergy, + UnitOfPower, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo + +from homeassistant.components.powersensor.const import DOMAIN, ROLE_HOUSENET, ROLE_SOLAR, ROLE_WATER +from homeassistant.components.powersensor.powersensor_entity import PowersensorEntity, PowersensorSensorEntityDescription +from homeassistant.components.powersensor.sensor_measurements import SensorMeasurements + +_LOGGER = logging.getLogger(__name__) + + +_config: dict[SensorMeasurements, PowersensorSensorEntityDescription] = { + SensorMeasurements.BATTERY: PowersensorSensorEntityDescription( + key="Battery Level", + translation_key="battery_level", + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=0, + event="battery_level", + message_key="volts", + conversion_function=lambda v: max( + min(100.0 * (v - 3.3) / 0.85, 100), 0 + ), # 0% = 3.3 V , 100% = 4.15 V + ), + SensorMeasurements.WATTS: PowersensorSensorEntityDescription( + key="Power", + translation_key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=1, + event="average_power", + message_key="watts", + ), + SensorMeasurements.SUMMATION_ENERGY: PowersensorSensorEntityDescription( + key="Total Energy", + translation_key="total_energy", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=2, + state_class=SensorStateClass.TOTAL, + event="summation_energy", + message_key="summation_joules", + conversion_function=lambda v: v / 3600000.0, + ), + SensorMeasurements.ROLE: PowersensorSensorEntityDescription( + key="Device Role", + translation_key="device_role", + entity_category=EntityCategory.DIAGNOSTIC, + event="role", + message_key="role", + ), + SensorMeasurements.RSSI: PowersensorSensorEntityDescription( + key="Signal strength (Bluetooth)", + translation_key="rssi_ble", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + event="radio_signal_quality", + message_key="average_rssi", + ), +} + + +class PowersensorSensorEntity(PowersensorEntity): + """Powersensor Sensor Class--designed to handle all measurements of the sensor.""" + + def __init__( + self, + hass: HomeAssistant, + entry_id: str, + mac: str, + role: str, + measurement_type: SensorMeasurements, + ) -> None: + """Initialize the sensor.""" + super().__init__(hass, entry_id, mac, role, _config, measurement_type) + self.measurement_type = measurement_type + config: PowersensorSensorEntityDescription = _config[measurement_type] + self._measurement_name = config.key + self._current_translation_key: str = self._get_translation_key() + + @property + def device_info(self) -> DeviceInfo: + """DeviceInfo for PowersensorSensor. Includes mac, name and model.""" + return { + "identifiers": {(DOMAIN, self._mac)}, + "manufacturer": "Powersensor", + "model": "PowersensorSensor", + "translation_key": self._current_translation_key, + "translation_placeholders": { + "id": self._mac + }, + } + + def _get_translation_key(self) -> str: + role2key = { + ROLE_HOUSENET: "mains_sensor", + ROLE_SOLAR: "solar_sensor", + ROLE_WATER: "water_sensor", + } + return ( + role2key[self._role] + if self._role in [ROLE_HOUSENET, ROLE_WATER, ROLE_SOLAR] + else "unknown_sensor" + ) + + def _rename_based_on_role(self) -> bool: + expected_key: str = self._get_translation_key() + if self._current_translation_key != expected_key: + self._current_translation_key = expected_key + return True + return False diff --git a/homeassistant/components/powersensor/quality_scale.yaml b/homeassistant/components/powersensor/quality_scale.yaml new file mode 100644 index 00000000000000..c6ffaae0b0aaa5 --- /dev/null +++ b/homeassistant/components/powersensor/quality_scale.yaml @@ -0,0 +1,33 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: + status: done + comment: | + Single platform only, so no shared entity.py base class. + Integration uses dispatcher-based local push; DataUpdateCoordinator/CoordinatorEntity are not used. + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + This integration does not subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done diff --git a/homeassistant/components/powersensor/sensor.py b/homeassistant/components/powersensor/sensor.py new file mode 100644 index 00000000000000..371760f81631da --- /dev/null +++ b/homeassistant/components/powersensor/sensor.py @@ -0,0 +1,240 @@ +"""Sensor platform for the integration.""" + +from __future__ import annotations + +import asyncio +import copy +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ( + # Used config entry fields + CFG_ROLES, + # Used signals + CREATE_PLUG_SIGNAL, + CREATE_SENSOR_SIGNAL, + PLUG_ADDED_TO_HA_SIGNAL, + # Used roles + ROLE_APPLIANCE, + ROLE_HOUSENET, + ROLE_SOLAR, + ROLE_UPDATE_SIGNAL, + # Used runtime_data entries + RT_DISPATCHER, + RT_VHH, + RT_VHH_LOCK, + RT_VHH_MAINS_ADDED, + RT_VHH_SOLAR_ADDED, + SENSOR_ADDED_TO_HA_SIGNAL, + UPDATE_VHH_SIGNAL, +) +from .powersensor_message_dispatcher import PowersensorMessageDispatcher +from homeassistant.components.powersensor.plug_measurements import PlugMeasurements +from homeassistant.components.powersensor.powersensor_household_entity import ( + ConsumptionMeasurements, + PowersensorHouseholdEntity, + ProductionMeasurements, +) +from homeassistant.components.powersensor.powersensor_plug_entity import PowersensorPlugEntity +from homeassistant.components.powersensor.powersensor_sensor_entity import PowersensorSensorEntity +from homeassistant.components.powersensor.sensor_measurements import SensorMeasurements + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Powersensor sensors.""" + vhh = entry.runtime_data[RT_VHH] + dispatcher: PowersensorMessageDispatcher = entry.runtime_data[RT_DISPATCHER] + + entry.runtime_data[RT_VHH_LOCK] = asyncio.Lock() + entry.runtime_data[RT_VHH_MAINS_ADDED] = False + entry.runtime_data[RT_VHH_SOLAR_ADDED] = False + + plug_role = ROLE_APPLIANCE + entry_id = entry.entry_id + + def with_solar(): + """Checks whether any known sensor has the solar role.""" + return ROLE_SOLAR in entry.data.get(CFG_ROLES, {}).values() + + def with_mains(): + """Checks whether any known sensor has the house-net role.""" + return ROLE_HOUSENET in entry.data.get(CFG_ROLES, {}).values() + + # + # Role update support + # + async def handle_role_update(mac_address: str, new_role: str): + """Persists role updates and signals for VHH update if needed.""" + new_data = copy.deepcopy({**entry.data}) + if CFG_ROLES not in new_data: + new_data[CFG_ROLES] = {} + roles = new_data[CFG_ROLES] + old_role = roles.get(mac_address, None) + if old_role is None or old_role != new_role: + _LOGGER.debug( + "Updating role for %s from %s to %s", + mac_address, + old_role, + new_role, + ) + roles[mac_address] = new_role + hass.config_entries.async_update_entry(entry, data=new_data) + + # Note: for house-net/solar/appliance <-> water we'd need to change the entities too + + # Note: we don't currently support dynamically removing/disabling VHH + # entities if a solar/house-net sensor disappears. + if new_role in [ROLE_SOLAR, ROLE_HOUSENET]: + async_dispatcher_send(hass, UPDATE_VHH_SIGNAL) + + entry.async_on_unload( + async_dispatcher_connect(hass, ROLE_UPDATE_SIGNAL, handle_role_update) + ) + + # + # Automatic sensor discovery + # + async def handle_discovered_sensor(sensor_mac: str, sensor_role: str): + """Registers sensor entities, signals sensor added plus VHH update if needed.""" + new_sensors = [ + PowersensorSensorEntity( + hass, entry_id, sensor_mac, sensor_role, SensorMeasurements.BATTERY + ), + PowersensorSensorEntity( + hass, entry_id, sensor_mac, sensor_role, SensorMeasurements.WATTS + ), + PowersensorSensorEntity( + hass, entry_id, sensor_mac, sensor_role, SensorMeasurements.SUMMATION_ENERGY + ), + PowersensorSensorEntity( + hass, entry_id, sensor_mac, sensor_role, SensorMeasurements.ROLE + ), + PowersensorSensorEntity( + hass, entry_id, sensor_mac, sensor_role, SensorMeasurements.RSSI + ), + ] + async_add_entities(new_sensors, True) + async_dispatcher_send(hass, SENSOR_ADDED_TO_HA_SIGNAL, sensor_mac, sensor_role) + + if (sensor_role == ROLE_SOLAR and with_mains()) or sensor_role == ROLE_HOUSENET: + async_dispatcher_send(hass, UPDATE_VHH_SIGNAL) + + entry.async_on_unload( + async_dispatcher_connect(hass, CREATE_SENSOR_SIGNAL, handle_discovered_sensor) + ) + + # + # Plug handling + # + async def create_plug(plug_mac_address: str, new_plug_role: str): + """Registers sensor entities.""" + this_plug_sensors = [ + PowersensorPlugEntity( + hass, entry_id, plug_mac_address, new_plug_role, PlugMeasurements.WATTS + ), + PowersensorPlugEntity( + hass, entry_id, plug_mac_address, new_plug_role, PlugMeasurements.VOLTAGE + ), + PowersensorPlugEntity( + hass, entry_id, plug_mac_address, new_plug_role, PlugMeasurements.APPARENT_CURRENT + ), + PowersensorPlugEntity( + hass, entry_id, plug_mac_address, new_plug_role, PlugMeasurements.ACTIVE_CURRENT + ), + PowersensorPlugEntity( + hass, entry_id, plug_mac_address, new_plug_role, PlugMeasurements.REACTIVE_CURRENT + ), + PowersensorPlugEntity( + hass, entry_id, plug_mac_address, new_plug_role, PlugMeasurements.SUMMATION_ENERGY + ), + PowersensorPlugEntity( + hass, entry_id, plug_mac_address, new_plug_role, PlugMeasurements.ROLE + ), + ] + + async_add_entities(this_plug_sensors, True) + + for plug_mac in dispatcher.plugs: + await create_plug(plug_mac, plug_role) + + # + # Automatic plug discovery + # + async def handle_discovered_plug( + plug_mac_address: str, host: str, port: int, name: str + ): + """Registers sensor entities, signals plug added.""" + await create_plug(plug_mac_address, plug_role) + async_dispatcher_send( + hass, PLUG_ADDED_TO_HA_SIGNAL, plug_mac_address, host, port, name + ) + + entry.async_on_unload( + async_dispatcher_connect(hass, CREATE_PLUG_SIGNAL, handle_discovered_plug) + ) + await dispatcher.process_plug_queue() + + # Possibly unnecessary but will add sensors where the messages came in early + # Hopefully keeps wait time less than 30s + for mac, role in dispatcher.on_start_sensor_queue.items(): + await handle_discovered_sensor(mac, role) + + # + # Virtual household support + # + async def update_virtual_household_entities(): + """Enables VHH entities based on solar/house-net availability.""" + async with entry.runtime_data[RT_VHH_LOCK]: + if not with_mains(): + _LOGGER.debug("No house-net, VHH not yet operational") + return # No VHH until we have at least house-net + + mains_added = entry.runtime_data[RT_VHH_MAINS_ADDED] + solar_added = entry.runtime_data[RT_VHH_SOLAR_ADDED] + + household_entities = [] + + if with_mains() and not mains_added: + _LOGGER.debug("Enabling mains components in virtual household") + household_entities.extend( + [ + PowersensorHouseholdEntity(vhh, measurement_type) + for measurement_type in ConsumptionMeasurements + ] + ) + + entry.runtime_data[RT_VHH_MAINS_ADDED] = True + + if with_solar() and not solar_added: + _LOGGER.debug("Enabling solar components in virtual household") + household_entities.extend( + [ + PowersensorHouseholdEntity(vhh, solar_measurement_type) + for solar_measurement_type in ProductionMeasurements + ] + ) + entry.runtime_data[RT_VHH_SOLAR_ADDED] = True + + if len(household_entities) > 0: + async_add_entities(household_entities) + + entry.async_on_unload( + async_dispatcher_connect( + hass, UPDATE_VHH_SIGNAL, update_virtual_household_entities + ) + ) + + await update_virtual_household_entities() diff --git a/homeassistant/components/powersensor/sensor_measurements.py b/homeassistant/components/powersensor/sensor_measurements.py new file mode 100644 index 00000000000000..32cc8ce53dbd1f --- /dev/null +++ b/homeassistant/components/powersensor/sensor_measurements.py @@ -0,0 +1,13 @@ +"""Enum determining what measurements a Powersensor sensor can report.""" + +from enum import Enum + + +class SensorMeasurements(Enum): + """Enum to keep track of what measurements sensors can report.""" + + BATTERY = 1 + WATTS = 2 + SUMMATION_ENERGY = 3 + ROLE = 4 + RSSI = 5 diff --git a/homeassistant/components/powersensor/strings.json b/homeassistant/components/powersensor/strings.json new file mode 100644 index 00000000000000..4486a6cc3860e1 --- /dev/null +++ b/homeassistant/components/powersensor/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "cannot_connect": "Failed to connect", + "cannot_reconfigure": "Cannot reconfigure. Initial configuration incomplete or broken.", + "firmware_not_compatible": "Plug firmware not compatible", + "no_devices_found": "No devices found on the network", + "roles_applied": "Roles successfully applied!", + "single_instance_allowed": "Already configured. Only a single configuration possible." + }, + "error": { + "unknown": "Unexpected error" + }, + "step": { + "discovery_confirm": { + "description": "Do you want to add Powersensor to Home Assistant?", + "title": "Powersensor plugs discovered" + }, + "manual_confirm": { + "description": "No Powersensor plugs were discovered on your network. You can still add the integration and if plugs are later discovered they will be added to Home Assistant. Do you want to add Powersensor to Home Assistant?", + "title": "No Powersensor plugs discovered" + }, + "reconfigure": { + "description": "Note: Roles provided by sensors will override user settings", + "title": "Update sensor roles" + } + } + } +} diff --git a/homeassistant/components/powersensor/translations/en.json b/homeassistant/components/powersensor/translations/en.json new file mode 100644 index 00000000000000..27f8f4ef27296f --- /dev/null +++ b/homeassistant/components/powersensor/translations/en.json @@ -0,0 +1,105 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "cannot_connect": "Failed to connect", + "cannot_reconfigure": "Cannot reconfigure. Initial configuration incomplete or broken.", + "firmware_not_compatible": "Plug firmware not compatible", + "no_devices_found": "No devices found on the network", + "roles_applied": "Roles successfully applied!", + "single_instance_allowed": "Already configured. Only a single configuration possible." + }, + "error": { + "unknown": "Unexpected error" + }, + "step": { + "discovery_confirm": { + "description": "Do you want to add Powersensor to Home Assistant?", + "title": "Powersensor plugs discovered" + }, + "manual_confirm": { + "description": "No Powersensor plugs were discovered on your network. You can still add the integration and if plugs are later discovered they will be added to Home Assistant. Do you want to add Powersensor to Home Assistant?", + "title": "No Powersensor plugs discovered" + }, + "reconfigure": { + "description": "Note: Roles provided by sensors will override user settings", + "title": "Update sensor roles" + } + } + }, + "entity": { + "sensor": { + "power": { + "name": "Power" + }, + "volts": { + "name": "Volts" + }, + "apparent_current": { + "name": "Apparent Current" + }, + "active_current": { + "name": "Active Current" + }, + "reactive_current": { + "name": "Reactive Current" + }, + "total_energy": { + "name": "Total Energy" + }, + "device_role": { + "name": "Device Role" + }, + "battery_level": { + "name": "Battery Level" + }, + "rssi_ble": { + "name": "Signal strength (Bluetooth)" + }, + "power_home_use": { + "name": "Power - Home use" + }, + "power_from_grid": { + "name": "Power - From grid" + }, + "power_to_grid": { + "name": "Power - To grid" + }, + "power_solar_generation": { + "name": "Power - Solar generation" + }, + "energy_home_use": { + "name": "Energy - Home usage" + }, + "energy_from_grid": { + "name": "Energy - From grid" + }, + "energy_to_grid": { + "name": "Energy - To grid" + }, + "energy_solar_generation": { + "name": "Energy - Solar generation" + } + } + }, + "device": { + "virtual_household_view": { + "name": "Powersensor Household View 🏠" + }, + "plug": { + "name": "Powersensor Plug (ID: {id})" + }, + "mains_sensor": { + "name": "Powersensor Mains Sensor ⚡" + }, + "solar_sensor": { + "name": "Powersensor Solar Sensor ☀️" + }, + "water_sensor": { + "name": "Powersensor Water Sensor 💧" + }, + "unknown_sensor": { + "name": "Powersensor Sensor (ID: {id})" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 13a421d03185d7..fda9f93fc9cb33 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -560,6 +560,7 @@ "portainer", "powerfox", "powerfox_local", + "powersensor", "powerwall", "prana", "private_ble_device", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e544f83988a0bb..de366e57e7937f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5373,6 +5373,13 @@ } } }, + "powersensor": { + "name": "Powersensor", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "single_config_entry": true + }, "prana": { "name": "Prana", "integration_type": "device", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 9f602f3c50147d..ebf8a8a160416c 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -856,6 +856,11 @@ "domain": "plugwise", }, ], + "_powersensor._udp.local.": [ + { + "domain": "powersensor", + }, + ], "_powerview._tcp.local.": [ { "domain": "hunterdouglas_powerview", diff --git a/requirements_all.txt b/requirements_all.txt index 5a882843e238b4..3cffffba2bb833 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1808,6 +1808,9 @@ poolsense==0.0.8 # homeassistant.components.powerfox_local powerfox==2.1.1 +# homeassistant.components.powersensor +powersensor-local==2.1.0 + # homeassistant.components.prana prana-api-client==0.12.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 19e973b2f31a42..04d983a26ba849 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1569,6 +1569,9 @@ poolsense==0.0.8 # homeassistant.components.powerfox_local powerfox==2.1.1 +# homeassistant.components.powersensor +powersensor-local==2.1.0 + # homeassistant.components.prana prana-api-client==0.12.0 diff --git a/tests/components/powersensor/__init__.py b/tests/components/powersensor/__init__.py new file mode 100644 index 00000000000000..e2398157f92fe7 --- /dev/null +++ b/tests/components/powersensor/__init__.py @@ -0,0 +1 @@ +"""Tests for the Powersensor integration.""" diff --git a/tests/components/powersensor/conftest.py b/tests/components/powersensor/conftest.py new file mode 100644 index 00000000000000..4dd23442090e6f --- /dev/null +++ b/tests/components/powersensor/conftest.py @@ -0,0 +1,78 @@ +"""Common test fixtures for powersensor Home Assistant integration tests.""" + +from powersensor_local import PlugListenerUdp +import pytest +import zeroconf + +from homeassistant.components.powersensor.config_flow import PowersensorConfigFlow +from homeassistant.components.powersensor.const import DOMAIN +import homeassistant.components.zeroconf +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def auto_enable_custom_integrations(enable_custom_integrations: None) -> None: + """Placeholder fixture that is a no-op for enabling custom integrations.""" + + +@pytest.fixture(autouse=True) +def no_powersensor_local(monkeypatch: pytest.MonkeyPatch) -> None: + """Monkeypatch for UDP listener making 'connect' a no-op safe for testing.""" + + def no_connect(self): + pass + + monkeypatch.setattr(PlugListenerUdp, "connect", no_connect) + + +@pytest.fixture(autouse=True) +def no_zeroconf(monkeypatch: pytest.MonkeyPatch) -> None: + """Monkeypatch for turning off zeroconf.""" + + async def no_zc(hass: HomeAssistant | None): + return None + + monkeypatch.setattr(homeassistant.components.zeroconf, "async_get_instance", no_zc) + + def empty_zc_init(self, service_type, listener, _): + pass + + monkeypatch.setattr(zeroconf.ServiceBrowser, "__init__", empty_zc_init) + + +@pytest.fixture +def def_config_entry(): + """A mock config entry for powersensor integration testing.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "devices": { + "0123456789abcd": { + "name": "test-plug", + "display_name": "Test Plug", + "mac": "0123456789abcd", + "host": "192.168.0.33", + "port": 49476, + } + }, + "with_solar": False, + "roles": { + "c001eat5": "house-net", + "cafebabe": "solar", + "d3adB33f": "", + }, + }, + entry_id="test", + version=PowersensorConfigFlow.VERSION, + minor_version=1, + state=ConfigEntryState.LOADED, + ) + + class MockDispatcher: + sensors = ["coo1eat5", "cafebabe", "d3adB33f"] + + entry.runtime_data = {"dispatcher": MockDispatcher()} + return entry diff --git a/tests/components/powersensor/test_config_flow.py b/tests/components/powersensor/test_config_flow.py new file mode 100644 index 00000000000000..9c966548958404 --- /dev/null +++ b/tests/components/powersensor/test_config_flow.py @@ -0,0 +1,532 @@ +"""Tests for the powersensor Home Assistant config_flow. + +This module includes various unit tests to ensure that the configuration flow for +the power sensor component works correctly. +""" + +import asyncio +from ipaddress import ip_address +from unittest.mock import AsyncMock + +import pytest + +from homeassistant import config_entries +import homeassistant.components.powersensor +from homeassistant.components.powersensor import PowersensorConfigFlow +from homeassistant.components.powersensor.const import ( + DOMAIN, + ROLE_UPDATE_SIGNAL, + RT_DISPATCHER +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +MAC = "a4cf1218f158" +SECOND_MAC = "a4cf1218f160" + + +@pytest.fixture(autouse=True) +def bypass_setup(monkeypatch: pytest.MonkeyPatch): + """A pytest fixture to bypass the actual setup of the powersensor component during tests. + + It replaces the async_setup_entry method with a mock that returns True. + """ + monkeypatch.setattr( + homeassistant.components.powersensor, + "async_setup_entry", + AsyncMock(return_value=True), + ) + + +def validate_config_data(data): + """Validates the configuration data received from the powersensor config flow. + + Args: + data (dict): The configuration data to be validated. + + Raises: + AssertionError: If the configuration data does not meet the expected format. + """ + assert isinstance(data["devices"], dict) + assert isinstance(data["roles"], dict) + + +### Tests ################################################ + + +async def test_user(hass: HomeAssistant) -> None: + """Tests the user-initiated configuration flow for the powersensor.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "manual_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"next_step_id": result["step_id"]}, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + validate_config_data(result["data"]) + + +async def test_zeroconf(hass: HomeAssistant) -> None: + """Tests the zeroconf-initiated configuration flow for the powersensor.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("192.168.0.33"), + ip_addresses=[ip_address("192.168.0.33")], + hostname=f"Powersensor-gateway-{MAC}-civet.local", + name=f"Powersensor-gateway-{MAC}-civet._powersensor._udp.local", + port=49476, + type="_powersensor._udp.local.", + properties={ + "version": "1", + "id": f"{MAC}", + }, + ), + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"next_step_id": result["step_id"]}, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + validate_config_data(result["data"]) + assert MAC in result["data"]["devices"] + + +async def test_zeroconf_two_plugs(hass: HomeAssistant) -> None: + """This test ensures that the configuration flow correctly handles the discovery of two Powersensor plugs via Zeroconf, with subsequent discoveries being aborted if the integration is already configured.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("192.168.0.33"), + ip_addresses=[ip_address("192.168.0.33")], + hostname=f"Powersensor-gateway-{MAC}-civet.local", + name=f"Powersensor-gateway-{MAC}-civet._powersensor._udp.local", + port=49476, + type="_powersensor._udp.local.", + properties={ + "version": "1", + "id": f"{MAC}", + }, + ), + ) + + second_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("192.168.0.37"), + ip_addresses=[ip_address("192.168.0.37")], + hostname=f"Powersensor-gateway-{SECOND_MAC}-civet.local", + name=f"Powersensor-gateway-{SECOND_MAC}-civet._powersensor._udp.local", + port=49476, + type="_powersensor._udp.local.", + properties={ + "version": "1", + "id": f"{SECOND_MAC}", + }, + ), + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"next_step_id": result["step_id"]}, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + validate_config_data(result["data"]) + assert MAC in result["data"]["devices"] + + # # we expect the second plug config flow to get canceled if the integration has already been configured + assert second_result["type"] == FlowResultType.ABORT + + +async def test_zeroconf_two_plugs_race( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Simulate a race condition where two PowerSensor devices are discovered simultaneously, testing that the second device gets cancelled and the first one completes the config flow.""" + + # WIP: this may not yet really simulate the race condition previously observed in HA + call_count = 0 + original_set_unique_id = PowersensorConfigFlow.async_set_unique_id + + async def delayed_set_unique_id(self, *args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + await asyncio.sleep(1.0) + return await original_set_unique_id(self, *args, **kwargs) + + monkeypatch.setattr( + PowersensorConfigFlow, "async_set_unique_id", delayed_set_unique_id + ) + task1 = asyncio.create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("192.168.0.33"), + ip_addresses=[ip_address("192.168.0.33")], + hostname=f"Powersensor-gateway-{MAC}-civet.local", + name=f"Powersensor-gateway-{MAC}-civet._powersensor._udp.local", + port=49476, + type="_powersensor._udp.local.", + properties={ + "version": "1", + "id": f"{MAC}", + }, + ), + ) + ) + await asyncio.sleep(0.99) + task2 = asyncio.create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("192.168.0.37"), + ip_addresses=[ip_address("192.168.0.37")], + hostname=f"Powersensor-gateway-{SECOND_MAC}-civet.local", + name=f"Powersensor-gateway-{SECOND_MAC}-civet._powersensor._udp.local", + port=49476, + type="_powersensor._udp.local.", + properties={ + "version": "1", + "id": f"{SECOND_MAC}", + }, + ), + ) + ) + result, second_result = await asyncio.gather(task1, task2) + + assert second_result["type"] == FlowResultType.FORM + assert second_result["step_id"] == "discovery_confirm" + + second_result = await hass.config_entries.flow.async_configure( + second_result["flow_id"], + user_input={"next_step_id": second_result["step_id"]}, + ) + assert second_result["type"] == FlowResultType.CREATE_ENTRY + validate_config_data(second_result["data"]) + assert SECOND_MAC in second_result["data"]["devices"] + + # # # we expect the plug arriving second in config flow to get canceled if the integration has already been configured + assert result["type"] == FlowResultType.ABORT + + +async def test_zeroconf_two_plugs_skipping_unique_id( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test behavior when the PowerSensor integration is configured before the second plug device is discovered. + + This test expects the first plug to complete the config flow, but the second plug's config flow to be skipped. + However, the current behavior does not match this expectation. + """ + call_count = 0 + original_set_unique_id = PowersensorConfigFlow.async_set_unique_id + + async def delayed_set_unique_id(self, *args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 2: + return None + return await original_set_unique_id(self, *args, **kwargs) + + monkeypatch.setattr( + PowersensorConfigFlow, "async_set_unique_id", delayed_set_unique_id + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("192.168.0.33"), + ip_addresses=[ip_address("192.168.0.33")], + hostname=f"Powersensor-gateway-{MAC}-civet.local", + name=f"Powersensor-gateway-{MAC}-civet._powersensor._udp.local", + port=49476, + type="_powersensor._udp.local.", + properties={ + "version": "1", + "id": f"{MAC}", + }, + ), + ) + + second_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("192.168.0.37"), + ip_addresses=[ip_address("192.168.0.37")], + hostname=f"Powersensor-gateway-{SECOND_MAC}-civet.local", + name=f"Powersensor-gateway-{SECOND_MAC}-civet._powersensor._udp.local", + port=49476, + type="_powersensor._udp.local.", + properties={ + "version": "1", + "id": f"{SECOND_MAC}", + }, + ), + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"next_step_id": result["step_id"]}, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + validate_config_data(result["data"]) + assert MAC in result["data"]["devices"] + + assert second_result["type"] == FlowResultType.ABORT + + +async def test_zeroconf_already_discovered(hass: HomeAssistant) -> None: + """Test behavior when trying to discover and configure a PowerSensor device that has already been discovered. + + This test checks that: + - The first discovery attempt completes the config flow. + - A second discovery attempt from the same IP address is aborted with the 'already_in_progress' reason. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("192.168.0.33"), + ip_addresses=[ip_address("192.168.0.33")], + hostname=f"Powersensor-gateway-{MAC}-civet.local", + name=f"Powersensor-gateway-{MAC}-civet._powersensor._udp.local", + port=49476, + type="_powersensor._udp.local.", + properties={ + "version": "1", + "id": f"{MAC}", + }, + ), + ) + + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("192.168.0.33"), + ip_addresses=[ip_address("192.168.0.33")], + hostname=f"Powersensor-gateway-{MAC}-civet.local", + name=f"Powersensor-gateway-{MAC}-civet._powersensor._udp.local", + port=49476, + type="_powersensor._udp.local.", + properties={ + "version": "1", + "id": f"{MAC}", + }, + ), + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_in_progress" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"next_step_id": result["step_id"]}, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + validate_config_data(result["data"]) + assert MAC in result["data"]["devices"] + + +async def test_zeroconf_missing_id(hass: HomeAssistant) -> None: + """No plug should advertise without an 'id' property, but just in case...""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("192.168.0.33"), + ip_addresses=[ip_address("192.168.0.33")], + hostname=f"Powersensor-gateway-{MAC}-civet.local", + name=f"Powersensor-gateway-{MAC}-civet._powersensor._udp.local", + port=49476, + type="_powersensor._udp.local.", + properties={ + "version": "1", + }, + ), + ) + assert result["type"] == FlowResultType.ABORT + + +@pytest.mark.parametrize("check_translations", [None]) +async def test_reconfigure( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch, def_config_entry +) -> None: + """Validates the device reconfiguration flow and role updates.""" + + # Make the config_flow use our precanned entry + def my_entry(_): + return def_config_entry + + monkeypatch.setattr(hass.config_entries, "async_get_entry", my_entry) + # Kick off the reconfigure + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": def_config_entry.entry_id, + }, + ) + assert result["type"] == FlowResultType.FORM + + # Hook into role updates to see role change come through + called = 0 + + async def verify_roles(mac, role): + nonlocal called + called += 1 + assert mac == "cafebabe" and role == "water" + + discon = async_dispatcher_connect(hass, ROLE_UPDATE_SIGNAL, verify_roles) + + # Prepare user_input, and submit it + mac2name = { + mac: SENSOR_NAME_FORMAT % mac + for mac in def_config_entry.runtime_data["dispatcher"].sensors + } + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={mac2name["cafebabe"]: "water"} + ) + discon() + # Verify + assert result["type"] == FlowResultType.ABORT + assert called == 1 + + +@pytest.mark.parametrize("check_translations", [None]) +async def test_unknown_role( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch, def_config_entry +) -> None: + """Tests the system's response to unknown roles during configuration step.""" + + # Make the config_flow use our pre-canned entry + def my_entry(_): + return def_config_entry + + monkeypatch.setattr(hass.config_entries, "async_get_entry", my_entry) + + # Kick off the reconfigure + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": def_config_entry.entry_id, + }, + ) + assert result["type"] == FlowResultType.FORM + + # Hook into role updates to see role change come through + called = 0 + + async def verify_roles(mac, role): + nonlocal called + called += 1 + assert mac == "d3adB33f" and role is None + + discon = async_dispatcher_connect(hass, ROLE_UPDATE_SIGNAL, verify_roles) + + # Prepare user_input, and submit it + mac2name = { + mac: SENSOR_NAME_FORMAT % mac + for mac in def_config_entry.runtime_data["dispatcher"].sensors + } + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={mac2name["d3adB33f"]: ""} + ) + discon() + # Verify + assert result["type"] == FlowResultType.ABORT + assert called == 1 + + +async def test_abort_due_to_missing_runtime_data( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch, def_config_entry +) -> None: + """Tests the system's response to missing runtime data during the configuration step.""" + del def_config_entry.runtime_data + + # Make the config_flow use our pre-canned entry + def my_entry(_): + return def_config_entry + + monkeypatch.setattr(hass.config_entries, "async_get_entry", my_entry) + + # Kick off the reconfigure + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": def_config_entry.entry_id, + }, + ) + assert result["type"] == FlowResultType.ABORT + + +async def test_abort_due_to_missing_dispatcher( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch, def_config_entry +) -> None: + """Tests the system's response to missing dispatcher in the runtime data during the configuration step.""" + def_config_entry.runtime_data[RT_DISPATCHER] = None + + # Make the config_flow use our pre-canned entry + def my_entry(_): + return def_config_entry + + monkeypatch.setattr(hass.config_entries, "async_get_entry", my_entry) + + # Kick off the reconfigure + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": def_config_entry.entry_id, + }, + ) + assert result["type"] == FlowResultType.ABORT + + +async def test_user_already_configured(hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch) -> None: + """Test behavior when trying to discover and configure a PowerSensor device that has already been discovered. + + This test checks that: + - The first discovery attempt completes the config flow. + - A second discovery attempt from the same IP address is aborted with the 'already_in_progress' reason. + """ + def always_true(*args, **kwargs): + return True + monkeypatch.setattr( + PowersensorConfigFlow, "_async_in_progress", always_true + ) + + + monkeypatch.setattr( + PowersensorConfigFlow, "async_set_unique_id", AsyncMock() + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == FlowResultType.ABORT + assert result['reason'] == "already_configured" \ No newline at end of file diff --git a/tests/components/powersensor/test_discovery_service.py b/tests/components/powersensor/test_discovery_service.py new file mode 100644 index 00000000000000..f15b29fe579a17 --- /dev/null +++ b/tests/components/powersensor/test_discovery_service.py @@ -0,0 +1,389 @@ +"""Tests related to Powersensor's Homeassistant Integrations's background zeroconf discovery process.""" + +import asyncio +import importlib +from ipaddress import ip_address +from unittest.mock import AsyncMock, Mock, call + +import pytest +from zeroconf import ServiceInfo + +from homeassistant.components.powersensor import PowersensorDiscoveryService +from homeassistant.components.powersensor.const import ( + ZEROCONF_ADD_PLUG_SIGNAL, + ZEROCONF_REMOVE_PLUG_SIGNAL, + ZEROCONF_UPDATE_PLUG_SIGNAL, +) +from homeassistant.components.powersensor.powersensor_discovery_service import ( + PowersensorServiceListener, +) +from homeassistant.core import HomeAssistant + +MAC = "a4cf1218f158" + + +@pytest.fixture +def mock_service_info(): + """Create a mock service info.""" + return ServiceInfo( + addresses=[ip_address("192.168.0.33").packed], + server=f"Powersensor-gateway-{MAC}-civet.local.", + name=f"Powersensor-gateway-{MAC}-civet._powersensor._udp.local.", + port=49476, + type_="_powersensor._udp.local.", + properties={ + "version": "1", + "id": f"{MAC}", + }, + ) + + +@pytest.mark.asyncio +async def test_discovery_add( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch, mock_service_info +) -> None: + """Test adding of services during Zeroconf discovery. + + This test verifies that: + - The `add_service` method retrieves the correct service info from Zeroconf. + - The added service trigger + """ + mock_send = Mock() + monkeypatch.setattr(PowersensorServiceListener, "dispatch", mock_send) + service = PowersensorServiceListener(hass) + mock_zc = Mock() + zc_info = mock_service_info + mock_zc.get_service_info.return_value = zc_info + + service.add_service(mock_zc, zc_info.type, zc_info.name) + mock_zc.get_service_info.assert_called_once_with(zc_info.type, zc_info.name) + + await hass.async_block_till_done() + await hass.async_block_till_done() + + mock_send.assert_called_once_with( + ZEROCONF_ADD_PLUG_SIGNAL, service._plugs[zc_info.name] + ) + + +@pytest.mark.asyncio +async def test_discovery_add_and_remove( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch, mock_service_info +) -> None: + """Test adding and removing of services during Zeroconf discovery. + + This test verifies that: + - The `add_service` method retrieves the correct service info from Zeroconf. + - The added service triggers the correct signal when sent to Home Assistant. + - The `remove_service` method removes the service correctly after a short debounce period. + """ + mock_send = Mock() + monkeypatch.setattr(PowersensorServiceListener, "dispatch", mock_send) + # set debounce timeout very short for testing + service = PowersensorServiceListener(hass, debounce_timeout=3) + mock_zc = Mock() + zc_info = mock_service_info + mock_zc.get_service_info.return_value = zc_info + + service.add_service(mock_zc, zc_info.type, zc_info.name) + mock_zc.get_service_info.assert_called_once_with(zc_info.type, zc_info.name) + + await hass.async_block_till_done() + await hass.async_block_till_done() + + mock_send.assert_called_once_with( + ZEROCONF_ADD_PLUG_SIGNAL, service._plugs[zc_info.name] + ) + + # reset mock_send + mock_send = Mock() + monkeypatch.setattr(PowersensorServiceListener, "dispatch", mock_send) + # cache plug data for checking + data = service._plugs[zc_info.name].copy() + + service.remove_service(mock_zc, zc_info.type, zc_info.name) + + # mock_zc.get_service_info.assert_called_once_with(zc_info.type, zc_info.name) + mock_zc.get_service_info.assert_called_once_with(zc_info.type, zc_info.name) + mock_send.assert_not_called() + await asyncio.sleep(service._debounce_seconds + 1) + + for _ in range(3): + await hass.async_block_till_done() + mock_send.assert_called_once_with(ZEROCONF_REMOVE_PLUG_SIGNAL, zc_info.name, data) + + +@pytest.mark.asyncio +async def test_discovery_remove_without_add( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch, mock_service_info +) -> None: + """Test removing of services during Zeroconf discovery without adding first. + + This test verifies that: + - The `remove_service` method does not call get_service_info if no add occurred. + - The `remove_service` method triggers the correct signal when sent to Home Assistant after a short debounce period. + """ + mock_send = Mock() + monkeypatch.setattr(PowersensorServiceListener, "dispatch", mock_send) + # set debounce timeout very short for testing + service = PowersensorServiceListener(hass, debounce_timeout=3) + mock_zc = Mock() + zc_info = mock_service_info + mock_zc.get_service_info.return_value = zc_info + + service.remove_service(mock_zc, zc_info.type, zc_info.name) + # mock_zc.get_service_info.assert_called_once_with(zc_info.type, zc_info.name) + mock_zc.get_service_info.assert_not_called() + mock_send.assert_not_called() + await asyncio.sleep(service._debounce_seconds + 1) + + for _ in range(3): + await hass.async_block_till_done() + mock_send.assert_called_once_with(ZEROCONF_REMOVE_PLUG_SIGNAL, zc_info.name, None) + + +@pytest.mark.asyncio +async def test_discovery_remove_cancel( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch, mock_service_info +) -> None: + """Test cancelling of service removal during Zeroconf discovery. + + This test verifies that: + - The `remove_service` method does not trigger dispatch when called after an add. + - The `get_service_info` method is called twice if add and remove are called in sequence. + """ + mock_send = Mock() + monkeypatch.setattr(PowersensorServiceListener, "dispatch", mock_send) + # set debounce timeout very short for testing + service = PowersensorServiceListener(hass, debounce_timeout=3) + mock_zc = Mock() + zc_info = mock_service_info + mock_zc.get_service_info.return_value = zc_info + + service.add_service(mock_zc, zc_info.type, zc_info.name) + mock_zc.get_service_info.assert_called_once_with(zc_info.type, zc_info.name) + + await hass.async_block_till_done() + await hass.async_block_till_done() + + mock_send.assert_called_once_with( + ZEROCONF_ADD_PLUG_SIGNAL, service._plugs[zc_info.name] + ) + + # reset mock_send + mock_send = Mock() + monkeypatch.setattr(PowersensorServiceListener, "dispatch", mock_send) + + # ensure we start from a known state + assert len(service._pending_removals) == 0 + + service.remove_service(mock_zc, zc_info.type, zc_info.name) + + # mock_zc.get_service_info.assert_called_once_with(zc_info.type, zc_info.name) + mock_zc.get_service_info.assert_called_once_with(zc_info.type, zc_info.name) + mock_send.assert_not_called() + assert len(service._pending_removals) == 1 + + # give the remove a head start before triggering the cancellation + await asyncio.sleep(0.5) + + # re-add the service, which should cancel the pending remove + service.add_service(mock_zc, zc_info.type, zc_info.name) + assert mock_zc.get_service_info.call_count == 2 + mock_zc.get_service_info.assert_has_calls( + [call(zc_info.type, zc_info.name), call(zc_info.type, zc_info.name)] + ) + + # let the removal task do its cancellation stuff + await asyncio.sleep(0.5) + assert len(service._pending_removals) == 0 + + +@pytest.mark.asyncio +async def test_discovery_add_and_two_remove_calls( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch, mock_service_info +) -> None: + """Test adding and removing of services during Zeroconf discovery with multiple remove calls. + + This test verifies that: + - The `remove_service` method does not trigger dispatch when called immediately after an add. + - The `remove_service` method triggers the correct signal after a short debounce period. + - Multiple remove calls are properly handled by the service. + """ + mock_send = Mock() + monkeypatch.setattr(PowersensorServiceListener, "dispatch", mock_send) + # set debounce timeout very short for testing + service = PowersensorServiceListener(hass, debounce_timeout=2) + mock_zc = Mock() + zc_info = mock_service_info + mock_zc.get_service_info.return_value = zc_info + + service.add_service(mock_zc, zc_info.type, zc_info.name) + mock_zc.get_service_info.assert_called_once_with(zc_info.type, zc_info.name) + + await hass.async_block_till_done() + await hass.async_block_till_done() + + mock_send.assert_called_once_with( + ZEROCONF_ADD_PLUG_SIGNAL, service._plugs[zc_info.name] + ) + + # reset mock_send + mock_send = Mock() + monkeypatch.setattr(PowersensorServiceListener, "dispatch", mock_send) + # cache plug data for checking + data = service._plugs[zc_info.name].copy() + + service.remove_service(mock_zc, zc_info.type, zc_info.name) + + # mock_zc.get_service_info.assert_called_once_with(zc_info.type, zc_info.name) + mock_zc.get_service_info.assert_called_once_with(zc_info.type, zc_info.name) + mock_send.assert_not_called() + await asyncio.sleep(service._debounce_seconds // 2 + 1) + service.remove_service(mock_zc, zc_info.type, zc_info.name) + await asyncio.sleep(service._debounce_seconds // 2 + 1) + for _ in range(3): + await hass.async_block_till_done() + mock_send.assert_called_once_with(ZEROCONF_REMOVE_PLUG_SIGNAL, zc_info.name, data) + + +@pytest.mark.asyncio +async def test_discovery_update( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch, mock_service_info +) -> None: + """Test updating of services during Zeroconf discovery. + + This test verifies that: + - The `update_service` method triggers the correct signal when called after an add. + - Service properties are updated correctly with new values from Zeroconf. + """ + mock_send = Mock() + monkeypatch.setattr(PowersensorServiceListener, "dispatch", mock_send) + # set debounce timeout very short for testing + service = PowersensorServiceListener(hass, debounce_timeout=2) + mock_zc = Mock() + zc_info = mock_service_info + mock_zc.get_service_info.return_value = zc_info + + service.add_service(mock_zc, zc_info.type, zc_info.name) + mock_zc.get_service_info.assert_called_once_with(zc_info.type, zc_info.name) + + await hass.async_block_till_done() + await hass.async_block_till_done() + + mock_send.assert_called_once_with( + ZEROCONF_ADD_PLUG_SIGNAL, service._plugs[zc_info.name] + ) + + # reset mock_send + mock_send = Mock() + monkeypatch.setattr(PowersensorServiceListener, "dispatch", mock_send) + updated_service_info = ServiceInfo( + addresses=[ip_address("192.168.0.34").packed], + server=f"Powersensor-gateway-{MAC}-civet.local.", + name=f"Powersensor-gateway-{MAC}-civet._powersensor._udp.local.", + port=49476, + type_="_powersensor._udp.local.", + properties={ + "version": "1", + "id": f"{MAC}", + }, + ) + mock_zc.get_service_info.return_value = updated_service_info + service.update_service( + mock_zc, updated_service_info.type, updated_service_info.name + ) + for _ in range(3): + await hass.async_block_till_done() + mock_send.assert_called_once_with( + ZEROCONF_UPDATE_PLUG_SIGNAL, service._plugs[zc_info.name] + ) + + assert len(service._plugs[zc_info.name]["addresses"]) == 1 + assert service._plugs[zc_info.name]["addresses"][0] == "192.168.0.34" + + +@pytest.mark.asyncio +async def test_discovery_dispatcher( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test dispatching of signals to the discovery service. + + This test verifies that: + - The `async_dispatcher_send` method is correctly called with signal and arguments. + """ + mod = importlib.import_module( + "homeassistant.components.powersensor.powersensor_discovery_service" + ) + mock_send = Mock() + monkeypatch.setattr(mod, "async_dispatcher_send", mock_send) + service = mod.PowersensorServiceListener(hass, debounce_timeout=4) + service.dispatch("mock_signal", 1, 2, 3, 4) + mock_send.assert_called_once_with(hass, "mock_signal", 1, 2, 3, 4) + + +@pytest.mark.asyncio +async def test_discovery_get_service_info( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch, mock_service_info +) -> None: + """Test retrieval of service info during Zeroconf discovery. + + This test verifies that: + - The `_async_get_service_info` method correctly retrieves and stores service info. + - Service discoveries are properly filtered by name. + """ + # set debounce timeout very short for testing + service = PowersensorServiceListener(hass, debounce_timeout=5) + mock_zc = AsyncMock() + zc_info = mock_service_info + + def custom_call_rules(type_, name, *args, **kwargs): + if type_ == zc_info.type and name == zc_info.name: + return zc_info + raise NotImplementedError + + mock_zc.async_get_service_info.side_effect = custom_call_rules + + await service._async_get_service_info(mock_zc, zc_info.type, zc_info.name) + + assert zc_info.name in service._discoveries + assert service._discoveries[zc_info.name] == zc_info + + await service._async_get_service_info(mock_zc, zc_info.type, "garbage_name") + assert "garbage_name" not in service._discoveries + + +@pytest.mark.asyncio +async def test_discovery_service_early_exit( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test early exit of the discovery service. + + This test verifies that: + - The `start` method correctly sets up the service to exit early. + """ + service = PowersensorDiscoveryService(hass) + service.running = True + await service.start() + + assert service.zc is None + assert service.listener is None + assert service.browser is None + + +@pytest.mark.asyncio +async def test_discovery_service_stop_with_canceled_task( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test stopping of the discovery service with an active task. + + This test verifies that: + - The `stop` method correctly cancels and stops the running task. + """ + service = PowersensorDiscoveryService(hass) + service.running = True + service.zc = Mock() + service._task = asyncio.create_task(asyncio.sleep(25)) + await service.stop() + assert service.zc is None + assert not service.running diff --git a/tests/components/powersensor/test_dispatcher.py b/tests/components/powersensor/test_dispatcher.py new file mode 100644 index 00000000000000..ad384255ca87c4 --- /dev/null +++ b/tests/components/powersensor/test_dispatcher.py @@ -0,0 +1,521 @@ +"""Tests related to Powersensor's Home Assistant integration's message dispatcher.""" + +import asyncio +import importlib +from ipaddress import ip_address +from unittest.mock import Mock, call + +import pytest + +from homeassistant.components.powersensor.const import ( + CFG_ROLES, + CREATE_PLUG_SIGNAL, + CREATE_SENSOR_SIGNAL, + DATA_UPDATE_SIGNAL_FMT_MAC_EVENT, + ROLE_UPDATE_SIGNAL, +) +from homeassistant.core import HomeAssistant + +MAC = "a4cf1218f158" + + +@pytest.fixture +def monkey_patched_dispatcher(hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch): + """Return a PowersensorMessageDispatcher instance with its dependencies monkey-patched. + + This fixture sets up a dispatcher with a mock dispatcher connect and send function, + as well as a mock virtual household. The `async_create_background_task` function on + the Home Assistant instance is also patched to create tasks synchronously. + """ + + def create_task(coroutine, name=None): + return asyncio.create_task(coroutine) + + monkeypatch.setattr(hass, "async_create_background_task", create_task) + + powersensor_dispatcher_module = importlib.import_module( + "homeassistant.components.powersensor.powersensor_message_dispatcher" + ) + + async_dispatcher_connect = Mock() + monkeypatch.setattr( + powersensor_dispatcher_module, + "async_dispatcher_connect", + async_dispatcher_connect, + ) + async_dispatcher_send = Mock() + monkeypatch.setattr( + powersensor_dispatcher_module, "async_dispatcher_send", async_dispatcher_send + ) + vhh = powersensor_dispatcher_module.VirtualHousehold(False) + entry = Mock() + entry.data = {CFG_ROLES: {}} + dispatcher = powersensor_dispatcher_module.PowersensorMessageDispatcher( + hass, entry, vhh, debounce_timeout=2 + ) + if not hasattr(dispatcher, "dispatch_send_reference"): + object.__setattr__(dispatcher, "dispatch_send_reference", {}) + dispatcher.dispatch_send_reference = async_dispatcher_send + + return dispatcher + + +@pytest.fixture +def network_info(): + """Return network information for the Powersensor gateway. + + This fixture provides a dictionary containing the MAC address, IP address, + port number, and name of the Powersensor gateway. + """ + return { + "mac": MAC, + "host": ip_address("192.168.0.33"), + "port": 49476, + "name": f"Powersensor-gateway-{MAC}-civet._powersensor._udp.local.", + } + + +@pytest.fixture +def zeroconf_discovery_info(): + """Return discovery information for the Powersensor gateway via Zeroconf. + + This fixture provides a dictionary containing information about the Powersensor + gateway, including its type, name, addresses, port number, and properties. + """ + return { + "type": "_powersensor._udp.local.", + "name": f"Powersensor-gateway-{MAC}-civet._powersensor._udp.local.", + "addresses": [ip_address("192.168.0.33")], + "port": 49476, + "server": f"Powersensor-gateway-{MAC}-civet.local.", + "properties": { + "version": "1", + b"id": f"{MAC}".encode(), + }, + } + + +async def follow_normal_add_sequence(dispatcher, network_info): + """Simulate adding a plug to Home Assistant via the normal add sequence. + + This function exercises the `enqueue_plug_for_adding`, `process_plug_queue`, + and `_acknowledge_plug_added_to_homeassistant` methods of the dispatcher. + It verifies that: + - The correct signal is sent when adding a plug. + - An API object is created for the added plug. + """ + assert not dispatcher.plugs + await dispatcher.enqueue_plug_for_adding(network_info) + await dispatcher.process_plug_queue() + for _ in range(3): + await dispatcher._hass.async_block_till_done() + + # check signal was sent to sensors + dispatcher.dispatch_send_reference.assert_called_once_with( + dispatcher._hass, + CREATE_PLUG_SIGNAL, + network_info["mac"], + network_info["host"], + network_info["port"], + network_info["name"], + ) + + # if we're at this point the signal should be coming back triggering acknowledge + await dispatcher._acknowledge_plug_added_to_homeassistant( + network_info["mac"], + network_info["host"], + network_info["port"], + network_info["name"], + ) + for _ in range(3): + await dispatcher._hass.async_block_till_done() + + # an api object should have been created + assert MAC in dispatcher.plugs + # Think this is a sign that the finally block is not running as expected. + # @todo: delete this block here as well after investigation complete + if dispatcher._monitor_add_plug_queue is not None: + dispatcher._monitor_add_plug_queue.cancel() + try: + await dispatcher._monitor_add_plug_queue + except asyncio.CancelledError: + pass + finally: + dispatcher._monitor_add_plug_queue = None + + +@pytest.mark.asyncio +async def test_dispatcher_monitor_plug_queue( + monkeypatch: pytest.MonkeyPatch, monkey_patched_dispatcher, network_info +) -> None: + """Test the `enqueue_plug_for_adding` and `process_plug_queue` methods of the dispatcher. + + This test verifies that: + - The correct API object is created when adding a plug. + - The queue is properly cleared after adding a plug. + """ + dispatcher = monkey_patched_dispatcher + + # mac address known, but not in plugs + dispatcher._known_plugs.add(MAC) + + assert not dispatcher.plugs + await dispatcher.enqueue_plug_for_adding(network_info) + await dispatcher.process_plug_queue() + for _ in range(3): + await dispatcher._hass.async_block_till_done() + + # an api object should have been created + assert MAC in dispatcher.plugs + # Think this is a sign that the finally block is not running as expected. + # @todo: investigate dispatcher plug queue watching task cleanup + if dispatcher._monitor_add_plug_queue is not None: + dispatcher._monitor_add_plug_queue.cancel() + try: + await dispatcher._monitor_add_plug_queue + except asyncio.CancelledError: + pass + finally: + dispatcher._monitor_add_plug_queue = None + + for _ in range(3): + await dispatcher._hass.async_block_till_done() + # try to see if queue gets properly cleared + await dispatcher.enqueue_plug_for_adding(network_info) + await dispatcher.process_plug_queue() + for _ in range(3): + await dispatcher._hass.async_block_till_done() + + assert MAC in dispatcher.plugs + + +@pytest.mark.asyncio +async def test_dispatcher_monitor_plug_queue_error_handling( + monkeypatch: pytest.MonkeyPatch, monkey_patched_dispatcher, network_info +) -> None: + """Test error handling when adding a plug to Home Assistant. + + This test verifies that: + - An error does not create an API object in the plugs dictionary. + """ + dispatcher = monkey_patched_dispatcher + + def raise_error(*args, **kwargs): + raise NotImplementedError + + monkeypatch.setattr(dispatcher, "_plug_has_been_seen", raise_error) + assert not dispatcher.plugs + await dispatcher.enqueue_plug_for_adding(network_info) + await dispatcher.process_plug_queue() + for _ in range(3): + await dispatcher._hass.async_block_till_done() + assert not dispatcher.plugs + + +@pytest.mark.asyncio +async def test_dispatcher_handle_plug_exception( + monkeypatch: pytest.MonkeyPatch, monkey_patched_dispatcher, network_info +) -> None: + """Test handling of plug exceptions by the dispatcher. + + This test verifies that: + - The `handle_exception` method does not crash when passed an exception. + """ + # for now, I pointlessly verify this does not crash + powersensor_dispatcher_module = importlib.import_module( + "homeassistant.components.powersensor.powersensor_message_dispatcher" + ) + await powersensor_dispatcher_module._handle_exception( + "exception", NotImplementedError + ) + + +@pytest.mark.asyncio +async def test_dispatcher_removal( + monkeypatch: pytest.MonkeyPatch, + monkey_patched_dispatcher, + network_info, + zeroconf_discovery_info, +) -> None: + """Test removal of plugs from Home Assistant via the dispatcher. + + This test verifies that: + - A plug can be removed when not yet added. + - A plug can be removed after being added. + - Pending removal tasks can be cancelled and removed. + - Interrupting a pending removal task prevents it from completing. + """ + dispatcher = monkey_patched_dispatcher + + # test removal of plug not added + await dispatcher._schedule_plug_removal( + network_info["name"], zeroconf_discovery_info + ) + await asyncio.sleep(dispatcher._debounce_seconds + 1) + for _ in range(3): + await dispatcher._hass.async_block_till_done() + + assert MAC not in dispatcher.plugs + + await follow_normal_add_sequence(dispatcher, network_info) + + await dispatcher._schedule_plug_removal( + network_info["name"], zeroconf_discovery_info + ) + await asyncio.sleep(dispatcher._debounce_seconds + 1) + for _ in range(3): + await dispatcher._hass.async_block_till_done() + assert MAC not in dispatcher.plugs + + await follow_normal_add_sequence(dispatcher, network_info) + assert MAC in dispatcher.plugs + await dispatcher._schedule_plug_removal( + network_info["name"], zeroconf_discovery_info + ) + + await asyncio.sleep(dispatcher._debounce_seconds // 2) + await dispatcher.stop_pending_removal_tasks() + await asyncio.sleep(dispatcher._debounce_seconds // 2 + 1) + for _ in range(3): + await dispatcher._hass.async_block_till_done() + # the removal should not have happened if it was interrupted + assert MAC in dispatcher.plugs + + # cancel just one mac + await dispatcher._schedule_plug_removal( + network_info["name"], zeroconf_discovery_info + ) + await dispatcher._schedule_plug_removal( + network_info["name"], zeroconf_discovery_info + ) + await asyncio.sleep(dispatcher._debounce_seconds // 2) + await dispatcher.cancel_any_pending_removal(MAC, "test-cancellation") + await asyncio.sleep(dispatcher._debounce_seconds // 2 + 1) + for _ in range(3): + await dispatcher._hass.async_block_till_done() + # the removal should not have happened if it was interrupted + assert MAC in dispatcher.plugs + + +@pytest.mark.asyncio +async def test_dispatcher_handle_relaying_for( + monkeypatch: pytest.MonkeyPatch, monkey_patched_dispatcher +) -> None: + """Test handling of relay events by the dispatcher. + + This test verifies that: + - Relay events are ignored when no device type or mac is specified. + - Relay events trigger dispatches with the correct signal and arguments. + """ + dispatcher = monkey_patched_dispatcher + await dispatcher.handle_relaying_for( + "test-event", {"mac": None, "device_type": "plug"} + ) + assert dispatcher.dispatch_send_reference.call_count == 0 + await dispatcher.handle_relaying_for( + "test-event", {"mac": None, "device_type": "sensor"} + ) + assert dispatcher.dispatch_send_reference.call_count == 0 + await dispatcher.handle_relaying_for( + "test-event", {"mac": MAC, "device_type": "plug"} + ) + assert dispatcher.dispatch_send_reference.call_count == 0 + await dispatcher.handle_relaying_for( + "test-event", {"mac": MAC, "device_type": "sensor", "role": "house-net"} + ) + assert dispatcher.dispatch_send_reference.call_count == 1 + assert dispatcher.dispatch_send_reference.call_args_list[0] == call( + dispatcher._hass, CREATE_SENSOR_SIGNAL, MAC, "house-net" + ) + + +@pytest.mark.asyncio +async def test_dispatcher_handle_relaying_for_none_role( + monkeypatch: pytest.MonkeyPatch, monkey_patched_dispatcher +) -> None: + """Test handling of relay events by the dispatcher. + + This test verifies that: + - A sensor with a missing role is registered without a role + """ + dispatcher = monkey_patched_dispatcher + await dispatcher.handle_relaying_for( + "test-event", {"mac": MAC, "device_type": "sensor", "role": None} + ) + assert dispatcher.dispatch_send_reference.call_count == 1 + assert dispatcher.dispatch_send_reference.call_args_list[0] == call( + dispatcher._hass, CREATE_SENSOR_SIGNAL, MAC, None + ) + + +@pytest.mark.asyncio +async def test_dispatcher_handle_relaying_for_unknown_role( + monkeypatch: pytest.MonkeyPatch, monkey_patched_dispatcher +) -> None: + """Test handling of relay events by the dispatcher. + + This test verifies that: + - A sensor with unknown role is registered without a role + """ + dispatcher = monkey_patched_dispatcher + await dispatcher.handle_relaying_for( + "test-event", {"mac": MAC, "device_type": "sensor", "role": "unknown"} + ) + assert dispatcher.dispatch_send_reference.call_count == 1 + assert dispatcher.dispatch_send_reference.call_args_list[0] == call( + dispatcher._hass, CREATE_SENSOR_SIGNAL, MAC, None + ) + + +@pytest.mark.asyncio +async def test_dispatcher_handle_relaying_for_unknown_role_with_stored_role( + monkeypatch: pytest.MonkeyPatch, monkey_patched_dispatcher +) -> None: + """Test handling of relay events by the dispatcher. + + This test verifies that: + - A sensor with unknown role for which we have a previously configured + role gets said configured role applied after creation + """ + dispatcher = monkey_patched_dispatcher + dispatcher._entry.data[CFG_ROLES][MAC] = "house-net" + await dispatcher.handle_relaying_for( + "test-event", {"mac": MAC, "device_type": "sensor", "role": "unknown"} + ) + assert dispatcher.dispatch_send_reference.call_count == 2 + assert dispatcher.dispatch_send_reference.call_args_list[0] == call( + dispatcher._hass, CREATE_SENSOR_SIGNAL, MAC, None + ) + assert dispatcher.dispatch_send_reference.call_args_list[1] == call( + dispatcher._hass, ROLE_UPDATE_SIGNAL, MAC, "house-net" + ) + + +@pytest.mark.asyncio +async def test_dispatcher_handle_message( + monkeypatch: pytest.MonkeyPatch, monkey_patched_dispatcher +) -> None: + """Test handling of messages by the dispatcher. + + This test verifies that: + - The `handle_message` method sends the correct signals when receiving sensor data. + """ + dispatcher = monkey_patched_dispatcher + role = "house-net" + event = "average_power" + message = {"mac": MAC, "device_type": "sensor", "role": role} + await dispatcher.handle_message(event, message) + assert dispatcher.dispatch_send_reference.call_count == 3 + assert dispatcher.dispatch_send_reference.call_args_list[0] == call( + dispatcher._hass, ROLE_UPDATE_SIGNAL, MAC, role + ) + assert dispatcher.dispatch_send_reference.call_args_list[1] == call( + dispatcher._hass, + DATA_UPDATE_SIGNAL_FMT_MAC_EVENT % (MAC, event), + event, + message, + ) + assert dispatcher.dispatch_send_reference.call_args_list[2] == call( + dispatcher._hass, + DATA_UPDATE_SIGNAL_FMT_MAC_EVENT % (MAC, "role"), + "role", + {"role": role}, + ) + event = "summation_energy" + await dispatcher.handle_message(event, message) + assert dispatcher.dispatch_send_reference.call_count == 6 + assert dispatcher.dispatch_send_reference.call_args_list[3] == call( + dispatcher._hass, ROLE_UPDATE_SIGNAL, MAC, role + ) + assert dispatcher.dispatch_send_reference.call_args_list[4] == call( + dispatcher._hass, + DATA_UPDATE_SIGNAL_FMT_MAC_EVENT % (MAC, event), + event, + message, + ) + assert dispatcher.dispatch_send_reference.call_args_list[5] == call( + dispatcher._hass, + DATA_UPDATE_SIGNAL_FMT_MAC_EVENT % (MAC, "role"), + "role", + {"role": role}, + ) + + +@pytest.mark.asyncio +async def test_dispatcher_acknowledge_added_to_homeassistant( + monkeypatch: pytest.MonkeyPatch, monkey_patched_dispatcher +) -> None: + """Test acknowledgement of sensors added to Home Assistant by the dispatcher. + + This test verifies that: + - The `sensors` dictionary is updated correctly when acknowledging a sensor. + """ + dispatcher = monkey_patched_dispatcher + dispatcher._acknowledge_sensor_added_to_homeassistant(MAC, "test-role") + assert dispatcher.sensors.get(MAC, None) == "test-role" + + +@pytest.mark.asyncio +async def test_dispatcher_plug_added( + monkeypatch: pytest.MonkeyPatch, monkey_patched_dispatcher, zeroconf_discovery_info +) -> None: + """Test adding of plugs by the dispatcher. + + This test verifies that: + - The `_plug_added` method can be called multiple times when safe. + """ + dispatcher = monkey_patched_dispatcher + await dispatcher._plug_added(zeroconf_discovery_info) + dispatcher._safe_to_process_plug_queue = True + await dispatcher._plug_added(zeroconf_discovery_info) + + +@pytest.mark.asyncio +async def test_dispatcher_plug_updated( + monkeypatch: pytest.MonkeyPatch, + monkey_patched_dispatcher, + network_info, + zeroconf_discovery_info, +) -> None: + """Test updating of plugs by the dispatcher. + + This test verifies that: + - The `_plug_updated` method sends the correct signal when a plug is updated. + - Plug updates are handled correctly even if the IP address or device has changed. + - Plug removals do not trigger an update. + """ + dispatcher = monkey_patched_dispatcher + await dispatcher._plug_updated(zeroconf_discovery_info) + + for _ in range(3): + await dispatcher._hass.async_block_till_done() + + dispatcher.dispatch_send_reference.assert_called_once_with( + dispatcher._hass, + CREATE_PLUG_SIGNAL, + network_info["mac"], + network_info["host"], + network_info["port"], + network_info["name"], + ) + assert MAC not in dispatcher.plugs + await follow_normal_add_sequence(dispatcher, network_info) + assert MAC in dispatcher.plugs + + await dispatcher._plug_updated(zeroconf_discovery_info) + + for _ in range(3): + await dispatcher._hass.async_block_till_done() + + assert dispatcher.dispatch_send_reference.call_count == 1 + assert MAC in dispatcher.plugs + # fake ip mismatch + dispatcher.plugs[MAC]._listener._ip = ip_address("192.168.0.34") + await dispatcher._plug_updated(zeroconf_discovery_info) + assert dispatcher.dispatch_send_reference.call_count == 1 + dispatcher.plugs[MAC]._listener._ip = ip_address("192.168.0.33") + # fake plug removal + assert MAC in dispatcher.plugs + await dispatcher.plugs[MAC].disconnect() + del dispatcher.plugs[MAC] + await dispatcher._plug_updated(zeroconf_discovery_info) diff --git a/tests/components/powersensor/test_init.py b/tests/components/powersensor/test_init.py new file mode 100644 index 00000000000000..392d9527bd547f --- /dev/null +++ b/tests/components/powersensor/test_init.py @@ -0,0 +1,147 @@ +"""pytest tests for initial configuration/loading of powersensor component in Home Assistant. + +This module contains unit tests to verify the functionality of the power sensor +component, including setup, migration, and entry management. +""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import homeassistant +from homeassistant.components.powersensor import ( + async_migrate_entry, + async_setup_entry, + async_unload_entry, +) +from homeassistant.components.powersensor.config_flow import PowersensorConfigFlow +from homeassistant.components.powersensor.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.loader import ( + DATA_COMPONENTS, + DATA_INTEGRATIONS, + DATA_MISSING_PLATFORMS, + DATA_PRELOAD_PLATFORMS, +) +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +### Fixtures ############################################### + + +@pytest.fixture +def hass_data(hass: HomeAssistant): + """Fixture to provide mock data for the Home Assistant environment.""" + hass.data = { + DATA_COMPONENTS: {}, + DATA_INTEGRATIONS: {}, + DATA_MISSING_PLATFORMS: {}, + DATA_PRELOAD_PLATFORMS: [], + } + + +### Tests ############################################### + + +async def test_async_setup(hass: HomeAssistant, hass_data) -> None: + """Test the async setup function for the power sensor component.""" + assert await async_setup_component(hass, DOMAIN, {}) is True + + +async def test_migrate_entry( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test the migration of config entries for the power sensor component.""" + updated = False + + def verify_new_entry(config_entry, data, version, minor_version) -> None: + nonlocal updated + updated = True + assert version == PowersensorConfigFlow.VERSION + assert minor_version == 2 + assert "devices" in data + assert "roles" in data + + monkeypatch.setattr(hass.config_entries, "async_update_entry", verify_new_entry) + + # Verify old config entry migration + old_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "0123456789ab": {}, # nothing looks inside this, so cheap out + }, + entry_id="test", + version=1, + minor_version=1, + ) + assert await async_migrate_entry(hass, old_entry) is True + assert updated + + # Verify new config entry doesn't migrate + new_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "0123456789ab": {}, # nothing looks inside this, so cheap out + }, + entry_id="test", + version=PowersensorConfigFlow.VERSION + 1, + minor_version=1, + ) + updated = False + assert await async_migrate_entry(hass, new_entry) is False + assert not updated + + +async def test_setup_unload_and_reload_entry( + hass: HomeAssistant, + hass_data, + def_config_entry, + monkeypatch: pytest.MonkeyPatch, + no_zeroconf, +) -> None: + """Test entry setup and unload.""" + mock_zc = AsyncMock() + mock_zc.async_close = AsyncMock() + mock_zc.loop = MagicMock() + mock_zc.loop.is_running.return_value = True + + async def get_mock_zc(*args, **kwargs): + return mock_zc + + monkeypatch.setattr( + homeassistant.components.zeroconf, "async_get_instance", get_mock_zc + ) + + monkeypatch.setattr( + "homeassistant.components.powersensor.powersensor_discovery_service.ServiceBrowser", + MagicMock(), + ) + + assert await async_setup_entry(hass, def_config_entry) + assert DOMAIN in hass.data and def_config_entry.entry_id in hass.data[DOMAIN] + + # Unload the entry and verify that the data has been removed + assert await async_unload_entry(hass, def_config_entry) + assert def_config_entry.entry_id not in hass.data[DOMAIN] + + +async def test_setup_exception( + hass: HomeAssistant, hass_data, def_config_entry, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test entry exception.""" + + ERRKEY = "Forced start failure" + + def fail_start(self): + raise RuntimeError(ERRKEY) + + monkeypatch.setattr( + "homeassistant.components.powersensor.powersensor_discovery_service.PowersensorDiscoveryService.start", + fail_start, + ) + with pytest.raises(ConfigEntryNotReady) as excinfo: + assert await async_setup_entry(hass, def_config_entry) + + assert ERRKEY in str(excinfo.value) diff --git a/tests/components/powersensor/test_powersensor_entity.py b/tests/components/powersensor/test_powersensor_entity.py new file mode 100644 index 00000000000000..eeba3613730b1b --- /dev/null +++ b/tests/components/powersensor/test_powersensor_entity.py @@ -0,0 +1,288 @@ +"""Tests covering the generic/abstract Powersensor Entity class and subclasses.""" + +import importlib +from unittest.mock import Mock + +from powersensor_local import VirtualHousehold +import pytest + +from homeassistant.components.powersensor.const import DOMAIN +from homeassistant.components.powersensor.powersensor_entity import ( + PowersensorEntity, + PowersensorSensorEntityDescription, +) +from homeassistant.components.powersensor.powersensor_household_entity import ( + HouseholdMeasurements, + PowersensorHouseholdEntity, +) +from homeassistant.components.powersensor.powersensor_sensor_entity import ( + PowersensorSensorEntity, +) +from homeassistant.components.powersensor.sensor_measurements import ( + SensorMeasurements, +) +from homeassistant.core import HomeAssistant + +MAC = "a4cf1218f158" + + +@pytest.fixture +def mock_config(): + """Create a mock service info.""" + return { + SensorMeasurements.SUMMATION_ENERGY: PowersensorSensorEntityDescription( + key="Total Energy", + device_class=None, + native_unit_of_measurement=None, + suggested_display_precision=2, + state_class=None, + event="summation_energy", + message_key="summation_joules", + conversion_function=lambda v: v / 3600000.0, + ) + } + + +### Tests ################################################ +@pytest.mark.asyncio +async def test_generic_powersensor_entity( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch, mock_config +) -> None: + """Test behavior of generic PowerSensor entities. + + This test verifies that: + - Generic entities raise an `NotImplementedError` when instantiated. + - Device info and availability tracking work as expected. + - Role updates are handled correctly, including renaming logic. + """ + + _config = mock_config + with pytest.raises(NotImplementedError): + PowersensorEntity( + hass, MAC, "house-net", _config, SensorMeasurements.SUMMATION_ENERGY + ) + + monkeypatch.setattr( + PowersensorEntity, + "device_info", + lambda self: { + "identifiers": {(DOMAIN, self._mac)}, + "manufacturer": "Powersensor", + "model": self._model, + "name": self._device_name, + }, + ) + + monkeypatch.setattr(PowersensorEntity, "async_write_ha_state", lambda self: None) + entity = PowersensorEntity( + hass, MAC, "house-net", _config, SensorMeasurements.SUMMATION_ENERGY + ) + assert not entity.available + assert entity._remove_unavailability_tracker is None + entity._handle_update("event", {}) + assert entity.available + + entity._schedule_unavailable() + assert entity._remove_unavailability_tracker is not None + entity._handle_update("event", {}) + assert entity._remove_unavailability_tracker is not None + assert callable(entity._remove_unavailability_tracker) + + await entity._async_make_unavailable(None) + assert not entity.available + + # this should not be implemented for generics and "renaming" should fail and be false + assert not entity._rename_based_on_role() + entity._handle_role_update(MAC + "garbage", "solar") + # should not have gotten renamed + assert entity._role == "house-net" + + # to trigger renaming logic for abstract clas we need to + entity._handle_role_update(MAC, "solar") + # should now be solar + assert entity._role == "solar" + + +@pytest.mark.asyncio +async def test_powersensor_sensor_default_name( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test behavior of PowersensorSensorEntity when using default names. + + This test verifies that: + - The entity's name and device name are correctly updated based on the role. + - The entity can be successfully added to Home Assistant. + """ + entity = PowersensorSensorEntity( + hass, MAC, "house-net", SensorMeasurements.SUMMATION_ENERGY + ) + entity._device_name = "bad_name" + entity._ensure_matching_prefix() + assert entity._device_name == "bad_name" + assert entity._attr_name == f"bad_name {entity._measurement_name}" + + entity._rename_based_on_role() + entity._rename_based_on_role() # activate other branch where renaming isn't required + assert entity._device_name == "Powersensor Mains Sensor ⚡" + entity._ensure_matching_prefix() + assert ( + entity._attr_name == f"Powersensor Mains Sensor ⚡ {entity._measurement_name}" + ) + + # try adding it to hass directly + await entity.async_added_to_hass() + + +@pytest.mark.asyncio +async def test_powersensor_virtual_household( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test behavior of PowersensorHouseholdEntity. + + This test verifies that: + - The entity can be successfully added to Home Assistant. + - The entity correctly updates its native value from events. + - The entity correctly handles resets and removals. + """ + vhh = VirtualHousehold(False) + # we'll do everything but write the HA state + monkeypatch.setattr( + PowersensorHouseholdEntity, "async_write_ha_state", lambda self: None + ) + power_from_grid_entity = PowersensorHouseholdEntity( + vhh, HouseholdMeasurements.POWER_FROM_GRID + ) + energy_from_grid = PowersensorHouseholdEntity( + vhh, HouseholdMeasurements.ENERGY_FROM_GRID + ) + + # does this error? + await power_from_grid_entity.async_added_to_hass() + await energy_from_grid.async_added_to_hass() + + # does these error? + await power_from_grid_entity._on_event("test-event", {"watts": 123}) + assert power_from_grid_entity.native_value == 123 + await energy_from_grid._on_event("test-event", {"summation_joules": 12356789}) + assert energy_from_grid.native_value == 12356789 / 3600000 + await energy_from_grid._on_event( + "test-event", {"summation_resettime_utc": 1762345678} + ) + + # does this error? + await power_from_grid_entity.async_will_remove_from_hass() + await energy_from_grid.async_will_remove_from_hass() + + +@pytest.mark.asyncio +async def test_entity_removal( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test removal of PowersensorSensorEntity. + + This test verifies that: + - The entity's unavailability tracker is correctly scheduled. + - The entity's unavailability tracker is called when removing from Home Assistant. + """ + entity = PowersensorSensorEntity( + hass, MAC, "house-net", SensorMeasurements.SUMMATION_ENERGY + ) + entity._has_recently_received_update_message = True # make available + + assert entity._remove_unavailability_tracker is None + entity._schedule_unavailable() + assert entity._remove_unavailability_tracker is not None + entity._remove_unavailability_tracker = Mock() + await entity.async_will_remove_from_hass() + entity._remove_unavailability_tracker.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_powersensor_sensor_handle_role_update( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test handling of role updates in PowersensorSensorEntity. + + This test verifies that: + - The entity's name and device name are correctly updated based on the new role. + - The Home Assistant registry is accessed when handling the update. + """ + + powersensor_entity_module = importlib.import_module( + "homeassistant.components.powersensor.sensor.powersensor_entity" + ) + er = Mock() + dr = Mock() + + device_registry = Mock() + device = Mock() + dr.async_get.return_value = device_registry + device_registry.async_get_device.return_value = device + + monkeypatch.setattr(powersensor_entity_module, "er", er) + monkeypatch.setattr(powersensor_entity_module, "dr", dr) + + write_state = Mock() + abstract_powersensor_entity_class = powersensor_entity_module.PowersensorEntity + monkeypatch.setattr( + abstract_powersensor_entity_class, "async_write_ha_state", write_state + ) + powersensor_sensor_entity_module = importlib.import_module( + "homeassistant.components.powersensor.sensor.powersensor_sensor_entity" + ) + + entity = powersensor_sensor_entity_module.PowersensorSensorEntity( + hass, MAC, "house-net", SensorMeasurements.SUMMATION_ENERGY + ) + entity._device_name = "bad_name" + entity._ensure_matching_prefix() + assert entity._device_name == "bad_name" + assert entity._attr_name == f"bad_name {entity._measurement_name}" + + entity._handle_role_update(MAC, "solar") + + assert entity._device_name == "Powersensor Solar Sensor ☀️" + assert entity._attr_name == f"Powersensor Solar Sensor ☀️ {entity._measurement_name}" + assert er.async_get.call_count == 1 + # try adding it to hass directly + await entity.async_added_to_hass() + + +@pytest.mark.asyncio +async def test_powersensor_entity_handle_update( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch, mock_config +) -> None: + """Test handling of updates in PowersensorEntity. + + This test verifies that: + - The entity correctly handles new measurements and updates its native value. + - The entity's _has_recently_received_update_message flag is set correctly. + """ + async_write_ha_state = Mock() + monkeypatch.setattr( + PowersensorEntity, + "device_info", + lambda self: { + "identifiers": {(DOMAIN, self._mac)}, + "manufacturer": "Powersensor", + "model": self._model, + "name": self._device_name, + }, + ) + monkeypatch.setattr(PowersensorEntity, "async_write_ha_state", async_write_ha_state) + _config = mock_config + entity = PowersensorEntity( + hass, MAC, "house-net", _config, SensorMeasurements.SUMMATION_ENERGY + ) + assert not entity._has_recently_received_update_message + + message = {"summation_joules": 123456789} + entity._handle_update(None, message) + assert entity._has_recently_received_update_message + assert entity.native_value == 123456789 / 3600000 + + entity._message_callback = None + entity._has_recently_received_update_message = False + entity._handle_update(None, message) + assert entity._has_recently_received_update_message + assert entity.native_value == 123456789 diff --git a/tests/components/powersensor/test_sensor_setup.py b/tests/components/powersensor/test_sensor_setup.py new file mode 100644 index 00000000000000..85de162a01f68e --- /dev/null +++ b/tests/components/powersensor/test_sensor_setup.py @@ -0,0 +1,129 @@ +"""tests relating to sensor setup for Powersensor's home assistant integration.""" + +from unittest.mock import AsyncMock, Mock + +from powersensor_local import VirtualHousehold +import pytest + +from homeassistant.components.powersensor import RT_DISPATCHER +from homeassistant.components.powersensor.const import ( + CREATE_SENSOR_SIGNAL, + DOMAIN, + ROLE_UPDATE_SIGNAL, + RT_VHH, + UPDATE_VHH_SIGNAL, +) +from homeassistant.components.powersensor.sensor import async_setup_entry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) + +from tests.common import MockConfigEntry + +MAC = "a4cf1218f158" +OTHER_MAC = "a4cf1218f159" + + +@pytest.fixture +def config_entry(): + """Return a mock config entry with populated runtime data. + + This fixture provides a basic config entry setup, including an empty dispatcher and sensor queue. + It's intended to be used as a starting point for more specific setups in tests. + """ + entry = MockConfigEntry(domain=DOMAIN) + runtime_data = {RT_VHH: VirtualHousehold(False), RT_DISPATCHER: AsyncMock()} + runtime_data[RT_DISPATCHER].plugs = {} + runtime_data[RT_DISPATCHER].on_start_sensor_queue = {} + entry.runtime_data = runtime_data + return entry + + +@pytest.mark.asyncio +async def test_setup_entry( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch, config_entry +) -> None: + """Test setup of an existing Powersensor config entry. + + This test verifies that: + - The `async_setup_entry` function calls `update_entry` with the correct arguments. + - The dispatcher sends a signal to update the VHH roles. + - The signal handler is called correctly. + """ + entry = config_entry + async_update_entry = Mock() + monkeypatch.setattr(hass.config_entries, "async_update_entry", async_update_entry) + entities = [] + + def callback(new_entities, *args, **kwargs): + entities.extend(new_entities) + + await async_setup_entry(hass, entry, callback) + mock_handler = Mock() + async_dispatcher_connect(hass, UPDATE_VHH_SIGNAL, mock_handler) + await hass.async_block_till_done() + + async_dispatcher_send(hass, ROLE_UPDATE_SIGNAL, MAC, "house-net") + for _ in range(4): + await hass.async_block_till_done() + + mock_handler.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_discovered_sensor( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch, config_entry +) -> None: + """Test discovery and creation of Powersensor entities. + + This test verifies that: + - The correct number of entities are created when discovering a sensor. + - Additional entities are correctly added for subsequent discoveries. + """ + entry = config_entry + async_update_entry = Mock() + monkeypatch.setattr(hass.config_entries, "async_update_entry", async_update_entry) + entities = [] + + def callback(new_entities, *args, **kwargs): + entities.extend(new_entities) + + await async_setup_entry(hass, entry, callback) + async_dispatcher_send(hass, CREATE_SENSOR_SIGNAL, MAC, "house-net") + for _ in range(10): + await hass.async_block_till_done() + + # check that the right number of entities have been added + assert len(entities) == 5 + # @todo: check that the correct entities are created + + async_dispatcher_send(hass, CREATE_SENSOR_SIGNAL, OTHER_MAC, "solar") + await hass.async_block_till_done() + # check that the right number of additional entities have been added + assert len(entities) == 10 + + +@pytest.mark.asyncio +async def test_initially_known_plugs_and_sensors( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch, config_entry +) -> None: + """Test setup of PowerSensor config entry with pre-existing plugs and sensors. + + This test verifies that: + - The correct number of entities are created based on the existing configuration. + """ + entry = config_entry + entry.runtime_data[RT_DISPATCHER].plugs[MAC] = None + entry.runtime_data[RT_DISPATCHER].on_start_sensor_queue[OTHER_MAC] = "house-net" + async_update_entry = Mock() + monkeypatch.setattr(hass.config_entries, "async_update_entry", async_update_entry) + entities = [] + + def callback(new_entities, *args, **kwargs): + entities.extend(new_entities) + + await async_setup_entry(hass, entry, callback) + + assert len(entities) == 12