diff --git a/.strict-typing b/.strict-typing index 1e8e498f8a189d..c9727f99617ea7 100644 --- a/.strict-typing +++ b/.strict-typing @@ -617,6 +617,7 @@ homeassistant.components.webostv.* homeassistant.components.websocket_api.* homeassistant.components.wemo.* homeassistant.components.whois.* +homeassistant.components.wibeee.* homeassistant.components.withings.* homeassistant.components.wiz.* homeassistant.components.wled.* diff --git a/CODEOWNERS b/CODEOWNERS index d79b2a229ec0c8..418191820ac587 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1966,6 +1966,8 @@ CLAUDE.md @home-assistant/core /tests/components/whirlpool/ @abmantis @mkmer /homeassistant/components/whois/ @frenck /tests/components/whois/ @frenck +/homeassistant/components/wibeee/ @fquinto +/tests/components/wibeee/ @fquinto /homeassistant/components/wiffi/ @mampfes /tests/components/wiffi/ @mampfes /homeassistant/components/wiim/ @Linkplay2020 diff --git a/homeassistant/components/wibeee/__init__.py b/homeassistant/components/wibeee/__init__.py new file mode 100644 index 00000000000000..c17740c155c7d7 --- /dev/null +++ b/homeassistant/components/wibeee/__init__.py @@ -0,0 +1,161 @@ +"""The Wibeee integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +import ipaddress +import logging +import socket +from xml.etree.ElementTree import ParseError as XMLParseError + +import aiohttp +from pywibeee import WibeeeAPI, WibeeeDeviceInfo + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + CONF_MAC_ADDRESS, + CONF_UPDATE_MODE, + CONF_WIBEEE_ID, + DEFAULT_SCAN_INTERVAL, + MODE_LOCAL_PUSH, + MODE_POLLING, + PUSH_STALE_AFTER, +) +from .coordinator import WibeeeCoordinator +from .push_receiver import async_setup_push_receiver + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.SENSOR] + + +@dataclass +class WibeeeRuntimeData: + """Runtime data stored in entry.runtime_data.""" + + api: WibeeeAPI + device_info: WibeeeDeviceInfo + coordinator: WibeeeCoordinator + + +WibeeeConfigEntry = ConfigEntry[WibeeeRuntimeData] + + +async def async_setup_entry(hass: HomeAssistant, entry: WibeeeConfigEntry) -> bool: + """Set up Wibeee from a config entry.""" + mode = entry.options.get(CONF_UPDATE_MODE, MODE_LOCAL_PUSH) + host = entry.data[CONF_HOST] + mac_addr = entry.data[CONF_MAC_ADDRESS] + wibeee_id = entry.data.get(CONF_WIBEEE_ID, "WIBEEE") + + _LOGGER.debug( + "Setting up Wibeee entry %s (mode=%s, host=%s)", + entry.entry_id, + mode, + host, + ) + + session = async_get_clientsession(hass) + api = WibeeeAPI(session, host) + + # Fetch device info + try: + device_info = await api.async_fetch_device_info(retries=3) + except (TimeoutError, aiohttp.ClientError) as err: + raise ConfigEntryNotReady(f"Could not connect to Wibeee at {host}") from err + + if device_info is None: + _LOGGER.warning("Could not get device info from %s, using fallback", host) + device_info = WibeeeDeviceInfo( + wibeee_id=wibeee_id, + mac_addr=mac_addr, + model="Unknown", + firmware_version="Unknown", + ip_addr=host, + ) + + # Create coordinator based on mode + if mode == MODE_POLLING: + coordinator = WibeeeCoordinator( + hass, + api, + config_entry=entry, + name=f"Wibeee {device_info.mac_addr_short}", + update_interval=DEFAULT_SCAN_INTERVAL, + ) + await coordinator.async_config_entry_first_refresh() + else: + # Push mode: no polling, data arrives via async_set_updated_data() + coordinator = WibeeeCoordinator( + hass, + api, + config_entry=entry, + name=f"Wibeee {device_info.mac_addr_short}", + update_interval=None, + stale_after=PUSH_STALE_AFTER, + ) + # Do one initial poll to discover available sensors + try: + initial_data = await api.async_fetch_sensors_data(retries=3) + except (TimeoutError, aiohttp.ClientError, XMLParseError) as err: + raise ConfigEntryNotReady(f"Error connecting to Wibeee at {host}") from err + + if not initial_data or not isinstance(initial_data, dict): + raise ConfigEntryNotReady( + f"Could not fetch initial sensor data from Wibeee at {host}" + ) + + # Seed the coordinator with the bootstrap data and arm the + # push staleness watchdog. + coordinator.async_push_update(initial_data) + + # Register with push receiver + # Ensure we use a concrete IP even if host is a hostname for validation + try: + resolved_ip = str(ipaddress.ip_address(host)) + except ValueError: + try: + resolved_ip = await hass.async_add_executor_job( + socket.gethostbyname, host + ) + resolved_ip = str(ipaddress.ip_address(resolved_ip)) + except (OSError, ValueError) as err: + raise ConfigEntryNotReady( + f"Could not resolve Wibeee host {host} to an IP address for push mode" + ) from err + + receiver = async_setup_push_receiver(hass) + receiver.register_device(mac_addr, resolved_ip, coordinator.async_push_update) + + entry.async_on_unload(lambda: receiver.unregister_device(mac_addr)) + + entry.runtime_data = WibeeeRuntimeData( + api=api, device_info=device_info, coordinator=coordinator + ) + + # Reload on options change + entry.async_on_unload(entry.add_update_listener(async_update_options)) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: WibeeeConfigEntry) -> bool: + """Unload a config entry.""" + _LOGGER.debug("Unloading Wibeee entry %s", entry.entry_id) + + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + del entry.runtime_data + return unload_ok + + +async def async_update_options(hass: HomeAssistant, entry: WibeeeConfigEntry) -> None: + """Handle options update - reload the entry.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/wibeee/config_flow.py b/homeassistant/components/wibeee/config_flow.py new file mode 100644 index 00000000000000..84bafe04dfa85d --- /dev/null +++ b/homeassistant/components/wibeee/config_flow.py @@ -0,0 +1,392 @@ +"""Config flow for Wibeee energy monitor.""" + +from __future__ import annotations + +from datetime import timedelta +import ipaddress +import logging +import socket +from typing import Any +from urllib.parse import urlparse + +import aiohttp +from pywibeee import WibeeeAPI +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.network import async_get_source_ip +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import AbortFlow +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.network import get_url +from homeassistant.helpers.selector import ( + BooleanSelector, + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo + +from . import WibeeeConfigEntry +from .const import ( + CONF_AUTO_CONFIGURE, + CONF_MAC_ADDRESS, + CONF_UPDATE_MODE, + CONF_WIBEEE_ID, + DOMAIN, + MODE_LOCAL_PUSH, + MODE_POLLING, +) + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_HA_PORT = 8123 + + +async def validate_input( + hass: HomeAssistant, data: dict[str, Any] +) -> tuple[str, str, dict[str, Any]]: + """Validate the user input allows us to connect.""" + session = async_get_clientsession(hass) + api = WibeeeAPI(session, data[CONF_HOST]) + + try: + device = await api.async_fetch_device_info(retries=3) + except (TimeoutError, aiohttp.ClientError) as exc: + raise NoDeviceInfo(f"Cannot connect: {exc}") from exc + + # The library returns ``None`` (instead of raising) when device info is + # incomplete, e.g. when the MAC cannot be determined; treat that as a + # connection failure for the user. + if device is None: + raise NoDeviceInfo("No device info received") + + # Normalize MAC for unique_id consistency + mac_clean = device.mac_addr_formatted.replace(":", "").lower() + + return ( + f"Wibeee {device.mac_addr_short}", + mac_clean, + { + CONF_HOST: data[CONF_HOST], + CONF_MAC_ADDRESS: mac_clean, + CONF_WIBEEE_ID: device.wibeee_id, + }, + ) + + +def _is_routable_ip(ip: str) -> bool: + """Check if IP is a valid routable address (not loopback/multicast/link-local).""" + try: + addr = ipaddress.ip_address(ip) + except ValueError: + return False + return not ( + addr.is_loopback + or addr.is_multicast + or addr.is_link_local + or addr.is_unspecified + ) + + +async def _async_configure_device(hass: HomeAssistant, host: str) -> bool: + """Configure the device for local push.""" + try: + local_ip = await _get_local_ip(hass) + if not _is_routable_ip(local_ip): + return False + + ha_port = _get_ha_port(hass) + session = async_get_clientsession(hass) + api = WibeeeAPI(session, host, timeout=timedelta(seconds=15)) + success = await api.async_configure_push_server(local_ip, ha_port) + if success: + _LOGGER.debug( + "Auto-configured WiBeee at %s to push to %s:%d", host, local_ip, ha_port + ) + return True + except TimeoutError, aiohttp.ClientError, OSError: + pass + return False + + +def _get_local_ip_sync() -> str: + """Determine local IP via socket (blocking, run in executor).""" + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect(("8.8.8.8", 80)) + return str(s.getsockname()[0]) + except OSError: + return "127.0.0.1" + finally: + s.close() + + +async def _get_local_ip(hass: HomeAssistant) -> str: + """Determine the local IP of the Home Assistant instance. + + Uses the network integration first, then falls back to ``get_url`` (only + if it returns an IP literal), and finally to a socket-based detection. + Hostnames are skipped because the WiBeee push-source check requires an + IP literal. + """ + try: + ip = await async_get_source_ip(hass) + except HomeAssistantError, OSError: + ip = None + if ip is not None: + return ip + + try: + url = get_url(hass, prefer_external=False) + except HomeAssistantError: + url = None + if url is not None: + host = urlparse(url).hostname + if host is not None: + try: + addr = ipaddress.ip_address(host) + except ValueError: + pass + else: + if not addr.is_loopback: + return host + + return await hass.async_add_executor_job(_get_local_ip_sync) + + +def _get_ha_port(hass: HomeAssistant) -> int: + """Get the port Home Assistant's HTTP server is listening on.""" + try: + url = get_url(hass, prefer_external=False) + except HomeAssistantError: + return DEFAULT_HA_PORT + + port = urlparse(url).port + if port is not None: + return port + return DEFAULT_HA_PORT + + +class WibeeeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Wibeee config flow.""" + + VERSION = 2 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._user_data: dict[str, Any] = {} + self._discovered_host: str | None = None + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> config_entries.ConfigFlowResult: + """Handle DHCP discovery of a Wibeee device.""" + host = discovery_info.ip + mac = discovery_info.macaddress.replace(":", "").lower() + + await self.async_set_unique_id(mac) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + + session = async_get_clientsession(self.hass) + api = WibeeeAPI(session, host, timeout=timedelta(seconds=5)) + try: + is_wibeee = await api.async_check_connection() + if not is_wibeee: + return self.async_abort(reason="not_wibeee_device") + except TimeoutError, aiohttp.ClientError: + return self.async_abort(reason="not_wibeee_device") + + self._discovered_host = host + return await self.async_step_user() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Step 1: User enters the device IP.""" + errors: dict[str, str] = {} + + if user_input is None and self._discovered_host: + user_input = {CONF_HOST: self._discovered_host} + + if user_input is not None: + try: + title, unique_id, data = await validate_input(self.hass, user_input) + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured(updates=user_input) + + self._user_data = data + self._user_data["_title"] = title + return await self.async_step_mode() + + except AbortFlow: + raise + except NoDeviceInfo: + errors[CONF_HOST] = "no_device_info" + except Exception: + _LOGGER.exception("Unexpected exception during setup") + errors["base"] = "unknown" + + default_host = (user_input or {}).get(CONF_HOST) or self._discovered_host or "" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_HOST, default=default_host): str} + ), + errors=errors, + ) + + async def async_step_mode( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Step 2: Choose update mode.""" + errors: dict[str, str] = {} + + if user_input is not None: + mode = user_input.get(CONF_UPDATE_MODE, MODE_LOCAL_PUSH) + auto_configure = user_input.get(CONF_AUTO_CONFIGURE, False) + + if mode == MODE_LOCAL_PUSH and auto_configure: + if not await _async_configure_device( + self.hass, self._user_data[CONF_HOST] + ): + errors["base"] = "auto_configure_failed" + + if not errors: + title = self._user_data.pop("_title") + return self.async_create_entry( + title=title, + data=self._user_data, + options={CONF_UPDATE_MODE: mode}, + ) + + return self.async_show_form( + step_id="mode", + data_schema=vol.Schema( + { + vol.Required( + CONF_UPDATE_MODE, default=MODE_LOCAL_PUSH + ): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict( + label="Local Push", value=MODE_LOCAL_PUSH + ), + SelectOptionDict(label="Polling", value=MODE_POLLING), + ], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_AUTO_CONFIGURE, default=True): BooleanSelector(), + } + ), + errors=errors, + ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: WibeeeConfigEntry, + ) -> WibeeeOptionsFlowHandler: + """Get the options flow handler.""" + return WibeeeOptionsFlowHandler() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Handle reconfiguration.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + try: + _, unique_id, data = await validate_input(self.hass, user_input) + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_mismatch(reason="wrong_device") + return self.async_update_reload_and_abort( + reconfigure_entry, data_updates=data + ) + except AbortFlow: + raise + except NoDeviceInfo: + errors[CONF_HOST] = "no_device_info" + except Exception: + _LOGGER.exception("Unexpected exception during reconfigure") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema( + { + vol.Required( + CONF_HOST, + default=reconfigure_entry.data.get(CONF_HOST, ""), + ): str + } + ), + errors=errors, + ) + + +class WibeeeOptionsFlowHandler(config_entries.OptionsFlow): + """Handle options flow.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Main options step.""" + errors: dict[str, str] = {} + options = dict(self.config_entry.options) + current_mode = options.get(CONF_UPDATE_MODE, MODE_LOCAL_PUSH) + + if user_input is not None: + new_mode = user_input.get(CONF_UPDATE_MODE, current_mode) + auto_configure = user_input.get(CONF_AUTO_CONFIGURE, False) + + if new_mode == MODE_LOCAL_PUSH and auto_configure: + if not await _async_configure_device( + self.hass, self.config_entry.data[CONF_HOST] + ): + errors["base"] = "auto_configure_failed" + + if not errors: + return self.async_create_entry( + title="", data={CONF_UPDATE_MODE: new_mode} + ) + + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required( + CONF_UPDATE_MODE, default=current_mode + ): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict( + label="Local Push", value=MODE_LOCAL_PUSH + ), + SelectOptionDict( + label="Polling", value=MODE_POLLING + ), + ], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional( + CONF_AUTO_CONFIGURE, default=False + ): BooleanSelector(), + } + ), + options, + ), + errors=errors, + ) + + +class NoDeviceInfo(HomeAssistantError): + """Error to indicate we could not get info from a Wibeee device.""" diff --git a/homeassistant/components/wibeee/const.py b/homeassistant/components/wibeee/const.py new file mode 100644 index 00000000000000..a5b857ee8aa844 --- /dev/null +++ b/homeassistant/components/wibeee/const.py @@ -0,0 +1,278 @@ +"""Constants for the Wibeee integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + DEGREE, + PERCENTAGE, + UnitOfApparentPower, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfFrequency, + UnitOfPower, + UnitOfReactiveEnergy, + UnitOfReactivePower, +) + + +# pylint: disable=hass-enforce-class-module +@dataclass(frozen=True, kw_only=True) +class WibeeeSensorEntityDescription(SensorEntityDescription): + """Describe a Wibeee sensor entity.""" + + +DOMAIN = "wibeee" + +DEFAULT_TIMEOUT = timedelta(seconds=10) +DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) +DEFAULT_HA_PORT = 8123 + +CONF_MAC_ADDRESS = "mac_address" +CONF_WIBEEE_ID = "wibeee_id" +CONF_SCAN_INTERVAL = "scan_interval" +CONF_UPDATE_MODE = "update_mode" +CONF_AUTO_CONFIGURE = "auto_configure" + +MODE_POLLING = "polling" +MODE_LOCAL_PUSH = "local_push" + +KNOWN_MODELS = { + "WBM": "Wibeee 1Ph", + "WBT": "Wibeee 3Ph", + "WMX": "Wibeee MAX", + "WTD": "Wibeee 3Ph RN", + "WX2": "Wibeee MAX 2S", + "WX3": "Wibeee MAX 3S", + "WXX": "Wibeee MAX MS", + "WBB": "Wibeee BOX", + "WB3": "Wibeee BOX S3P", + "W3P": "Wibeee 3Ph 3W", + "WGD": "Wibeee GND", + "WBP": "Wibeee SMART PLUG", +} + +PUSH_PARAM_TO_SENSOR: dict[str, str] = { + "v": "vrms", + "i": "irms", + "p": "p_aparent", + "a": "p_activa", + "r": "p_reactiva_ind", + "q": "frecuencia", + "f": "factor_potencia", + "e": "energia_activa", + "o": "energia_reactiva_ind", +} + +# Sensor keys that the local-push parser can refresh. +# Used to filter out polling-only metrics in push mode so the corresponding +# entities are not created (they would otherwise become unavailable as soon +# as the first push arrives). +PUSH_REFRESHABLE_SENSOR_KEYS: frozenset[str] = frozenset(PUSH_PARAM_TO_SENSOR.values()) + +PUSH_PHASE_MAP: dict[str, str] = { + "1": "fase1", + "2": "fase2", + "3": "fase3", + "t": "fase4", +} + +# Maximum time without receiving a push before push-mode data is considered +# stale and the coordinator marks itself as failed (so entities go +# unavailable instead of reporting last-known values forever). +PUSH_STALE_AFTER = timedelta(minutes=5) + + +SENSOR_TYPES: dict[str, WibeeeSensorEntityDescription] = { + "vrms": WibeeeSensorEntityDescription( + key="vrms", + translation_key="phase_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + "irms": WibeeeSensorEntityDescription( + key="irms", + translation_key="current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + "p_aparent": WibeeeSensorEntityDescription( + key="p_aparent", + translation_key="apparent_power", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + "p_activa": WibeeeSensorEntityDescription( + key="p_activa", + translation_key="active_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + "p_reactiva_ind": WibeeeSensorEntityDescription( + key="p_reactiva_ind", + translation_key="inductive_reactive_power", + native_unit_of_measurement=UnitOfReactivePower.VOLT_AMPERE_REACTIVE, + device_class=SensorDeviceClass.REACTIVE_POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + "p_reactiva_cap": WibeeeSensorEntityDescription( + key="p_reactiva_cap", + translation_key="capacitive_reactive_power", + native_unit_of_measurement=UnitOfReactivePower.VOLT_AMPERE_REACTIVE, + device_class=SensorDeviceClass.REACTIVE_POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + "frecuencia": WibeeeSensorEntityDescription( + key="frecuencia", + translation_key="frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + ), + "factor_potencia": WibeeeSensorEntityDescription( + key="factor_potencia", + translation_key="power_factor", + native_unit_of_measurement=None, + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + ), + "energia_activa": WibeeeSensorEntityDescription( + key="energia_activa", + translation_key="active_energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "energia_reactiva_ind": WibeeeSensorEntityDescription( + key="energia_reactiva_ind", + translation_key="inductive_reactive_energy", + native_unit_of_measurement=UnitOfReactiveEnergy.VOLT_AMPERE_REACTIVE_HOUR, + device_class=None, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + "energia_reactiva_cap": WibeeeSensorEntityDescription( + key="energia_reactiva_cap", + translation_key="capacitive_reactive_energy", + native_unit_of_measurement=UnitOfReactiveEnergy.VOLT_AMPERE_REACTIVE_HOUR, + device_class=None, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + "angle": WibeeeSensorEntityDescription( + key="angle", + translation_key="angle", + native_unit_of_measurement=DEGREE, + device_class=None, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + "thd_total": WibeeeSensorEntityDescription( + key="thd_total", + translation_key="thd_current", + native_unit_of_measurement=PERCENTAGE, + device_class=None, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + "thd_fund": WibeeeSensorEntityDescription( + key="thd_fund", + translation_key="thd_current_fundamental", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + "thd_ar3": WibeeeSensorEntityDescription( + key="thd_ar3", + translation_key="thd_current_harmonic_3", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + "thd_ar5": WibeeeSensorEntityDescription( + key="thd_ar5", + translation_key="thd_current_harmonic_5", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + "thd_ar7": WibeeeSensorEntityDescription( + key="thd_ar7", + translation_key="thd_current_harmonic_7", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + "thd_ar9": WibeeeSensorEntityDescription( + key="thd_ar9", + translation_key="thd_current_harmonic_9", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + "thd_tot_V": WibeeeSensorEntityDescription( + key="thd_tot_V", + translation_key="thd_voltage", + native_unit_of_measurement=PERCENTAGE, + device_class=None, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + "thd_fun_V": WibeeeSensorEntityDescription( + key="thd_fun_V", + translation_key="thd_voltage_fundamental", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + "thd_ar3_V": WibeeeSensorEntityDescription( + key="thd_ar3_V", + translation_key="thd_voltage_harmonic_3", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + "thd_ar5_V": WibeeeSensorEntityDescription( + key="thd_ar5_V", + translation_key="thd_voltage_harmonic_5", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + "thd_ar7_V": WibeeeSensorEntityDescription( + key="thd_ar7_V", + translation_key="thd_voltage_harmonic_7", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + "thd_ar9_V": WibeeeSensorEntityDescription( + key="thd_ar9_V", + translation_key="thd_voltage_harmonic_9", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), +} diff --git a/homeassistant/components/wibeee/coordinator.py b/homeassistant/components/wibeee/coordinator.py new file mode 100644 index 00000000000000..221d8819bea9e4 --- /dev/null +++ b/homeassistant/components/wibeee/coordinator.py @@ -0,0 +1,124 @@ +"""DataUpdateCoordinator for Wibeee energy monitors. + +Handles both update modes: +- **Polling**: Periodically fetches status.xml (update_interval > 0). +- **Push**: Receives data via HTTP push (update_interval=None). + Push data is injected via :meth:`async_push_update`. + A staleness watchdog marks the coordinator as failed if no push arrives + within ``stale_after``, so entities go unavailable instead of reporting + stale last-known values. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta +import logging +from typing import Any +from xml.etree.ElementTree import ParseError as XMLParseError + +import aiohttp +from pywibeee import WibeeeAPI + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + +WibeeeData = dict[str, dict[str, Any]] | None + + +class WibeeeCoordinator(DataUpdateCoordinator[WibeeeData]): + """Coordinator for Wibeee sensor data. + + Operates in one of two mutually exclusive modes selected at setup: + + - Polling: ``update_interval`` is set, ``_async_update_data`` fetches + from the device API on the standard DUC schedule. + - Push: ``update_interval`` is None (no polling). The coordinator acts + as a passive bus; data is injected via :meth:`async_push_update` from + the HTTP push receiver, and a watchdog marks the entity unavailable + if no push arrives within ``stale_after``. + """ + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + api: WibeeeAPI, + *, + config_entry: ConfigEntry, + name: str | None = None, + update_interval: timedelta | None = None, + stale_after: timedelta | None = None, + ) -> None: + """Initialize the coordinator.""" + self.api = api + self._stale_after = stale_after + self._stale_unsub: CALLBACK_TYPE | None = None + + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=name or f"Wibeee {api.host}", + update_interval=update_interval, + ) + + async def _async_update_data(self) -> WibeeeData: + """Fetch data from the Wibeee device (polling mode only).""" + try: + data = await self.api.async_fetch_sensors_data(retries=2) + except (TimeoutError, aiohttp.ClientError, XMLParseError) as exc: + raise UpdateFailed( + f"Error fetching data from {self.api.host}: {exc}" + ) from exc + + if data is None: + raise UpdateFailed(f"No data received from Wibeee at {self.api.host}") + + return data + + def async_push_update(self, data: WibeeeData) -> None: + """Receive push data from the HTTP receiver and update entities.""" + if not isinstance(data, dict): + _LOGGER.warning( + "Ignoring invalid push data for %s: expected dict, got %s", + self.name, + type(data).__name__, + ) + return + self.async_set_updated_data(data) + self._reschedule_staleness_check() + + @callback + def _reschedule_staleness_check(self) -> None: + """(Re)arm the push staleness watchdog.""" + if self._stale_after is None: + return + if self._stale_unsub is not None: + self._stale_unsub() + self._stale_unsub = None + self._stale_unsub = async_call_later( + self.hass, self._stale_after, self._handle_stale_data + ) + + @callback + def _handle_stale_data(self, _now: datetime) -> None: + """Mark coordinator as failed when push data is stale.""" + self._stale_unsub = None + message = ( + f"No push data received from {self.api.host} for " + f"{self._stale_after}; marking sensors unavailable" + ) + _LOGGER.warning(message) + self.async_set_update_error(UpdateFailed(message)) + + async def async_shutdown(self) -> None: + """Cancel the staleness watchdog and shut down the coordinator.""" + if self._stale_unsub is not None: + self._stale_unsub() + self._stale_unsub = None + await super().async_shutdown() diff --git a/homeassistant/components/wibeee/diagnostics.py b/homeassistant/components/wibeee/diagnostics.py new file mode 100644 index 00000000000000..c399233415cb7b --- /dev/null +++ b/homeassistant/components/wibeee/diagnostics.py @@ -0,0 +1,72 @@ +"""Diagnostics support for Wibeee integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import REDACTED, async_redact_data +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from . import WibeeeConfigEntry + +TO_REDACT = {CONF_HOST, "mac_address", "mac_addr", "mac"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: WibeeeConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + runtime = entry.runtime_data + + device_info = runtime.device_info + coordinator = runtime.coordinator + + # Gather push server config if available + push_config: dict[str, Any] | None = None + try: + push_config = await runtime.api.async_get_push_server_config() + except Exception: # noqa: BLE001 + push_config = {"error": "Could not retrieve push server config"} + + # Gather device configuration variables from values.xml and status.xml + device_diagnostics: dict[str, Any] = {} + try: + device_diagnostics = await runtime.api.async_fetch_device_diagnostics() + except Exception: # noqa: BLE001 + device_diagnostics = {"error": "Could not retrieve device diagnostics"} + + diag: dict[str, Any] = { + "entry": { + "data": async_redact_data(dict(entry.data), TO_REDACT), + "options": dict(entry.options), + }, + "device": { + "wibeee_id": device_info.wibeee_id, + "mac_addr": REDACTED, + "model": device_info.model, + "firmware_version": device_info.firmware_version, + "ip_addr": REDACTED, + }, + "device_config": async_redact_data(device_diagnostics, TO_REDACT), + "coordinator": { + "last_update_success": coordinator.last_update_success, + "update_interval": str(coordinator.update_interval), + "data": _redact_coordinator_data(coordinator.data), + }, + "push_server_config": ( + async_redact_data(push_config, TO_REDACT) if push_config else None + ), + } + + return diag + + +def _redact_coordinator_data( + data: dict[str, dict[str, Any]] | None, +) -> dict[str, dict[str, Any]] | None: + """Return coordinator data (sensor values are not sensitive).""" + if data is None: + return None + return {phase: dict(sensors) for phase, sensors in data.items()} diff --git a/homeassistant/components/wibeee/icons.json b/homeassistant/components/wibeee/icons.json new file mode 100644 index 00000000000000..a56dd43b9926fa --- /dev/null +++ b/homeassistant/components/wibeee/icons.json @@ -0,0 +1,78 @@ +{ + "entity": { + "sensor": { + "active_energy": { + "default": "mdi:pulse" + }, + "active_power": { + "default": "mdi:flash" + }, + "angle": { + "default": "mdi:angle-acute" + }, + "apparent_power": { + "default": "mdi:flash-circle" + }, + "capacitive_reactive_energy": { + "default": "mdi:alpha-e-circle-outline" + }, + "capacitive_reactive_power": { + "default": "mdi:flash-outline" + }, + "current": { + "default": "mdi:flash-auto" + }, + "frequency": { + "default": "mdi:current-ac" + }, + "inductive_reactive_energy": { + "default": "mdi:alpha-e-circle-outline" + }, + "inductive_reactive_power": { + "default": "mdi:flash-outline" + }, + "phase_voltage": { + "default": "mdi:sine-wave" + }, + "power_factor": { + "default": "mdi:math-cos" + }, + "thd_current": { + "default": "mdi:chart-bubble" + }, + "thd_current_fundamental": { + "default": "mdi:vector-point" + }, + "thd_current_harmonic_3": { + "default": "mdi:numeric-3" + }, + "thd_current_harmonic_5": { + "default": "mdi:numeric-5" + }, + "thd_current_harmonic_7": { + "default": "mdi:numeric-7" + }, + "thd_current_harmonic_9": { + "default": "mdi:numeric-9" + }, + "thd_voltage": { + "default": "mdi:chart-bubble" + }, + "thd_voltage_fundamental": { + "default": "mdi:vector-point" + }, + "thd_voltage_harmonic_3": { + "default": "mdi:numeric-3" + }, + "thd_voltage_harmonic_5": { + "default": "mdi:numeric-5" + }, + "thd_voltage_harmonic_7": { + "default": "mdi:numeric-7" + }, + "thd_voltage_harmonic_9": { + "default": "mdi:numeric-9" + } + } + } +} diff --git a/homeassistant/components/wibeee/manifest.json b/homeassistant/components/wibeee/manifest.json new file mode 100644 index 00000000000000..b142721eb8966a --- /dev/null +++ b/homeassistant/components/wibeee/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "wibeee", + "name": "Wibeee Energy Monitor", + "codeowners": ["@fquinto"], + "config_flow": true, + "dependencies": ["http", "network"], + "dhcp": [ + { + "macaddress": "001EC0*" + } + ], + "documentation": "https://www.home-assistant.io/integrations/wibeee", + "integration_type": "device", + "iot_class": "local_push", + "quality_scale": "bronze", + "requirements": ["pywibeee==0.1.3"] +} diff --git a/homeassistant/components/wibeee/push_receiver.py b/homeassistant/components/wibeee/push_receiver.py new file mode 100644 index 00000000000000..a91f0eb661241b --- /dev/null +++ b/homeassistant/components/wibeee/push_receiver.py @@ -0,0 +1,286 @@ +"""Local Push receiver for Wibeee energy monitors. + +Registers HTTP views within Home Assistant's built-in web server to receive +push data from WiBeee devices. The device sends periodic GET requests to +fixed paths: + - /Wibeee/receiverAvg (average data - main endpoint) + - /Wibeee/receiver (instantaneous data) + - /Wibeee/receiverLeap (gradient data) + +These paths are hardcoded in the WiBeee firmware and cannot be changed. +The device must be configured to point to the HA instance IP and port +(typically 8123). + +This module uses HomeAssistantView with ``requires_auth = False`` because +the WiBeee device has no ability to send authentication tokens. + +The PushReceiver is a singleton stored in ``hass.data[DATA_PUSH_RECEIVER]``. Each +config entry registers its MAC address so incoming push data is routed +to the correct sensor entities. + +Documentation: https://github.com/fquinto/pywibeee +""" + +from __future__ import annotations + +from collections.abc import Callable +import logging +from typing import cast + +from aiohttp.web import Request, Response + +from homeassistant.components.http import HomeAssistantView +from homeassistant.core import HomeAssistant + +from .const import DOMAIN, PUSH_PARAM_TO_SENSOR, PUSH_PHASE_MAP + +_LOGGER = logging.getLogger(__name__) + +# Key for the singleton PushReceiver in hass.data +DATA_PUSH_RECEIVER = f"{DOMAIN}_push_receiver" + +# Type alias for push data callback +PushDataCallback = Callable[[dict[str, dict[str, str]]], None] + + +class PushReceiver: + """Manages push data listeners for registered WiBeee devices. + + Each device is identified by its MAC address. When push data arrives, + the receiver parses it and calls the registered callback for that device. + Includes IP validation to reduce spoofing risk. + """ + + def __init__(self) -> None: + """Initialize the push receiver.""" + self._listeners: dict[str, PushDataCallback] = {} + self._device_ips: dict[str, str] = {} + + def register_device( + self, mac_address: str, ip_address: str, callback_fn: PushDataCallback + ) -> None: + """Register a device to receive push updates.""" + mac_clean = mac_address.replace(":", "").lower() + self._listeners[mac_clean] = callback_fn + self._device_ips[mac_clean] = ip_address + _LOGGER.debug( + "Registered push listener for MAC %s at IP %s (total: %d)", + mac_clean, + ip_address, + len(self._listeners), + ) + + def unregister_device(self, mac_address: str) -> None: + """Unregister a device from push updates.""" + mac_clean = mac_address.replace(":", "").lower() + self._listeners.pop(mac_clean, None) + self._device_ips.pop(mac_clean, None) + _LOGGER.debug( + "Unregistered push listener for MAC %s (remaining: %d)", + mac_clean, + len(self._listeners), + ) + + def get_listener(self, mac_address: str) -> PushDataCallback | None: + """Get the callback for a given MAC address.""" + mac_clean = mac_address.replace(":", "").lower() + return self._listeners.get(mac_clean) + + def validate_ip(self, mac_address: str, remote_ip: str | None) -> bool: + """Check if the request comes from the expected device IP.""" + if remote_ip is None: + return False + mac_clean = mac_address.replace(":", "").lower() + expected_ip = self._device_ips.get(mac_clean) + return remote_ip == expected_ip + + @property + def device_count(self) -> int: + """Return the number of registered devices.""" + return len(self._listeners) + + +def parse_push_data( + query_params: dict[str, str], +) -> dict[str, dict[str, str]]: + """Parse push query parameters into organized phase/sensor data. + + Input: {"mac": "001ec0112232", "v1": "230.5", "a1": "277", "vt": "230.5", ...} + Output: { + "fase1": {"vrms": "230.5", "p_activa": "277", ...}, + "fase4": {"vrms": "230.5", ...}, # "t" suffix -> fase4 (total) + } + """ + phases: dict[str, dict[str, str]] = {} + + for param, value in query_params.items(): + if len(param) < 2: + continue + + prefix = param[:-1] # e.g. "v" from "v1" + suffix = param[-1] # e.g. "1" from "v1" + + # Check if this is a known sensor parameter + sensor_key = PUSH_PARAM_TO_SENSOR.get(prefix) + phase_key = PUSH_PHASE_MAP.get(suffix) + + if sensor_key and phase_key: + if phase_key not in phases: + phases[phase_key] = {} + phases[phase_key][sensor_key] = value + + return phases + + +def _dispatch_push_data(receiver: PushReceiver, query: dict[str, str]) -> str: + """Dispatch push data to the correct device listener. + + Returns a log message describing what happened. + """ + mac_addr = query.get("mac", "").replace(":", "").lower() + if not mac_addr: + return "no MAC in push data" + + listener = receiver.get_listener(mac_addr) + if listener is None: + _LOGGER.warning("Push from unknown device ignored: %s", mac_addr) + return f"unregistered device {mac_addr}" + + parsed = parse_push_data(query) + if parsed: + listener(parsed) + return ( + f"device {mac_addr}: {len(parsed)} phases, " + f"{sum(len(v) for v in parsed.values())} values" + ) + return f"device {mac_addr}: no recognized sensors" + + +async def _handle_push_request( + receiver: PushReceiver, request: Request, response_text: str +) -> Response: + """Handle a push request from a WiBeee device. + + Performs basic validation to ensure the data matches a configured device. + """ + query = dict(request.query) + + # Require MAC in push data + mac_addr = query.get("mac", "").replace(":", "").lower() + if not mac_addr: + _LOGGER.debug("Push request missing MAC ignored") + return Response(status=400, text="missing MAC") + + # Validate device is registered + listener = receiver.get_listener(mac_addr) + if listener is None: + _LOGGER.debug("Push from unknown device rejected: %s", mac_addr) + return Response(status=403, text="unknown device") + + # Validate source IP to reduce spoofing risk + remote_ip = request.remote + if not receiver.validate_ip(mac_addr, remote_ip): + _LOGGER.debug( + "Push for %s from unauthorized IP rejected: %s (expected registered IP)", + mac_addr, + remote_ip, + ) + return Response(status=403, text="unauthorized source IP") + + # Process the push data + result = _dispatch_push_data(receiver, query) + _LOGGER.debug("push: %s", result) + return Response(status=200, text=response_text) + + +class WibeeeReceiverAvgView(HomeAssistantView): + """Handle /Wibeee/receiverAvg - the main push endpoint. + + The WiBeee device sends averaged sensor data as GET query parameters. + Expected response: ``<< None: + """Initialize with the push receiver instance.""" + self._receiver = receiver + + async def get(self, request: Request) -> Response: + """Handle incoming averaged push data from a WiBeee device.""" + return await _handle_push_request(self._receiver, request, "<< None: + """Initialize with the push receiver instance.""" + self._receiver = receiver + + async def get(self, request: Request) -> Response: + """Handle incoming instantaneous push data.""" + return await _handle_push_request(self._receiver, request, "<< None: + """Initialize with the push receiver instance.""" + self._receiver = receiver + + async def get(self, request: Request) -> Response: + """Handle incoming gradient push data.""" + return await _handle_push_request(self._receiver, request, "<< PushReceiver: + """Set up the push receiver and register HTTP views. + + Creates a singleton PushReceiver stored in ``hass.data`` and registers + the three WiBeee HTTP views on HA's built-in web server. + + This is idempotent: calling it multiple times returns the same receiver. + + Args: + hass: Home Assistant instance. + + Returns: + The PushReceiver instance. + """ + # Return existing receiver if already set up + if DATA_PUSH_RECEIVER in hass.data: + return cast(PushReceiver, hass.data[DATA_PUSH_RECEIVER]) + + receiver = PushReceiver() + + # Register the three push endpoints on HA's HTTP server + hass.http.register_view(WibeeeReceiverAvgView(receiver)) + hass.http.register_view(WibeeeReceiverView(receiver)) + hass.http.register_view(WibeeeReceiverLeapView(receiver)) + + hass.data[DATA_PUSH_RECEIVER] = receiver + + _LOGGER.info( + "Wibeee push receiver registered on HA HTTP server " + "(/Wibeee/receiverAvg, /Wibeee/receiver, /Wibeee/receiverLeap)" + ) + + return receiver diff --git a/homeassistant/components/wibeee/quality_scale.yaml b/homeassistant/components/wibeee/quality_scale.yaml new file mode 100644 index 00000000000000..6c3b168e745b95 --- /dev/null +++ b/homeassistant/components/wibeee/quality_scale.yaml @@ -0,0 +1,76 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not provide service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: This integration does not provide service actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: Device uses unauthenticated local HTTP; no credentials to re-auth. + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: done + discovery: done + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: Device phases are fixed hardware; no dynamic device addition. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: No known scenarios requiring repair flows. + stale-devices: + status: exempt + comment: Devices correspond to fixed hardware phases; no stale device scenario. + + # Platinum + async-dependency: done + inject-websession: + status: done + comment: aiohttp session is passed from HA to the API client. + strict-typing: done diff --git a/homeassistant/components/wibeee/sensor.py b/homeassistant/components/wibeee/sensor.py new file mode 100644 index 00000000000000..98b2fbd4ef1f66 --- /dev/null +++ b/homeassistant/components/wibeee/sensor.py @@ -0,0 +1,230 @@ +"""Wibeee sensor platform for Home Assistant. + +Creates sensor entities for each phase and sensor type detected on the +Wibeee energy monitor device. All sensors are ``CoordinatorEntity`` +instances backed by a single ``WibeeeCoordinator``: + +- **Polling mode**: Coordinator periodically fetches status.xml. +- **Push mode**: Coordinator receives data via ``async_push_update()``. + +Entity creation strategy: + Phases are **discovered** from the initial data fetch (hardware-dependent). + For each discovered phase, entities are created only for ``SENSOR_TYPES`` + whose keys are present in the initial phase data. + +Documentation: https://github.com/fquinto/pywibeee +""" + +from __future__ import annotations + +import logging + +from pywibeee import WibeeeDeviceInfo + +from homeassistant.components.sensor import SensorEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import WibeeeConfigEntry +from .const import ( + CONF_UPDATE_MODE, + DOMAIN, + KNOWN_MODELS, + MODE_LOCAL_PUSH, + PUSH_REFRESHABLE_SENSOR_KEYS, + SENSOR_TYPES, + WibeeeSensorEntityDescription, +) +from .coordinator import WibeeeCoordinator + +_LOGGER = logging.getLogger(__name__) + + +PARALLEL_UPDATES = 0 + +# Map phase names to human-readable labels +PHASE_NAMES: dict[str, str] = { + "fase1": "L1", + "fase2": "L2", + "fase3": "L3", + "fase4": "Total", +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: WibeeeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Wibeee sensor entities from a config entry.""" + runtime = entry.runtime_data + coordinator = runtime.coordinator + device_info = runtime.device_info + + # Discover phases from initial data (hardware-dependent). + # Single-phase: fase1 + fase4. Three-phase: fase1-3 + fase4. + data = coordinator.data + if data is None: + _LOGGER.warning( + "No data available for Wibeee %s (%s); no sensors created", + device_info.mac_addr_short, + device_info.ip_addr, + ) + return + + # Filter to known phases only + discovered_phases = [p for p in data if p in PHASE_NAMES] + if not discovered_phases: + _LOGGER.warning( + "No phases found for Wibeee %s (%s)", + device_info.mac_addr_short, + device_info.ip_addr, + ) + return + + # Build entities: discovered phases x sensor types present in data. + # In push mode, restrict to keys the push parser can refresh; otherwise + # any extra sensor (THD, angle, capacitive-reactive, ...) would become + # unavailable as soon as the first push update arrives. + is_push_mode = ( + entry.options.get(CONF_UPDATE_MODE, MODE_LOCAL_PUSH) == MODE_LOCAL_PUSH + ) + eligible_sensor_types: dict[str, WibeeeSensorEntityDescription] = ( + {k: v for k, v in SENSOR_TYPES.items() if k in PUSH_REFRESHABLE_SENSOR_KEYS} + if is_push_mode + else SENSOR_TYPES + ) + + # Process fase4 (Total) first to ensure the parent device exists + sorted_phases = sorted( + discovered_phases, + key=lambda p: (0 if p == "fase4" else 1, p), + ) + entities: list[WibeeeSensor] = [ + WibeeeSensor( + coordinator=coordinator, + device_info=device_info, + phase_key=phase_key, + description=description, + ) + for phase_key in sorted_phases + if isinstance(data.get(phase_key), dict) + for sensor_key, description in eligible_sensor_types.items() + if sensor_key in data[phase_key] + ] + + async_add_entities(entities) + _LOGGER.debug( + "Added %d sensors for Wibeee %s (%s) across %d phases", + len(entities), + device_info.mac_addr_short, + device_info.ip_addr, + len(sorted_phases), + ) + + +# --------------------------------------------------------------------------- +# Device info builder +# --------------------------------------------------------------------------- + + +def _build_device_info(device_info: WibeeeDeviceInfo, phase_key: str) -> dr.DeviceInfo: + """Build HA DeviceInfo for a sensor entity.""" + model_name = KNOWN_MODELS.get(device_info.model, f"Wibeee {device_info.model}") + is_phase = phase_key in ("fase1", "fase2", "fase3") + phase_label = PHASE_NAMES.get(phase_key, phase_key) + + if is_phase: + return dr.DeviceInfo( + identifiers={(DOMAIN, f"{device_info.mac_addr_formatted}_{phase_key}")}, + via_device=(DOMAIN, device_info.mac_addr_formatted), + name=f"Wibeee {device_info.mac_addr_short} {phase_label}", + model=f"{model_name} Clamp", + manufacturer="Smilics", + ) + return dr.DeviceInfo( + identifiers={(DOMAIN, device_info.mac_addr_formatted)}, + name=f"Wibeee {device_info.mac_addr_short}", + model=model_name, + manufacturer="Smilics", + sw_version=device_info.firmware_version, + configuration_url=f"http://{device_info.ip_addr}/", + ) + + +# --------------------------------------------------------------------------- +# Unified sensor entity (polling + push) +# --------------------------------------------------------------------------- + + +class WibeeeSensor(CoordinatorEntity[WibeeeCoordinator], SensorEntity): + """Wibeee sensor entity backed by a coordinator. + + Works for both polling and push modes. The coordinator provides + the data; the sensor reads its specific phase/key from it. + """ + + _attr_has_entity_name = True + entity_description: WibeeeSensorEntityDescription + + def __init__( + self, + coordinator: WibeeeCoordinator, + device_info: WibeeeDeviceInfo, + phase_key: str, + description: WibeeeSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + + self._phase_key = phase_key + self.entity_description = description + + self._attr_unique_id = ( + f"{device_info.mac_addr_formatted}_{phase_key}_{description.key}" + ) + self._attr_translation_key = description.translation_key + self._attr_device_info = _build_device_info(device_info, phase_key) + + @property + def native_value(self) -> float | None: + """Return the sensor value.""" + data = self.coordinator.data + if not isinstance(data, dict): + return None + phase_data = data.get(self._phase_key) + if not isinstance(phase_data, dict): + return None + value = phase_data.get(self.entity_description.key) + if value is None: + return None + try: + return float(value) + except ValueError, TypeError: + return None + + @property + def available(self) -> bool: + """Return True if the coordinator has data for this sensor. + + Extends CoordinatorEntity.available (which checks coordinator + connectivity) with phase/key-level granularity and value validation. + """ + if not super().available: + return False + data = self.coordinator.data + if not isinstance(data, dict): + return False + phase_data = data.get(self._phase_key) + if not isinstance(phase_data, dict): + return False + value = phase_data.get(self.entity_description.key) + if value is None: + return False + try: + float(value) + except ValueError, TypeError: + return False + return True diff --git a/homeassistant/components/wibeee/strings.json b/homeassistant/components/wibeee/strings.json new file mode 100644 index 00000000000000..af403888fea513 --- /dev/null +++ b/homeassistant/components/wibeee/strings.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "not_wibeee_device": "Discovered device is not a WiBeee energy monitor", + "reconfigure_successful": "Reconfiguration successful", + "wrong_device": "The device at this address has a different MAC address than the one being reconfigured" + }, + "error": { + "auto_configure_failed": "Failed to auto-configure the device for Local Push. You can configure it manually via the device web interface.", + "no_device_info": "Could not connect to the WiBeee device. Verify the IP address and that the device is powered on.", + "unknown": "An unknown error occurred." + }, + "step": { + "mode": { + "data": { + "auto_configure": "Auto-configure device for Local Push", + "update_mode": "Update mode" + }, + "data_description": { + "auto_configure": "If enabled, the integration will automatically configure your WiBeee to send data to this Home Assistant instance (IP and HTTP port). The device will restart to apply changes.", + "update_mode": "**Local Push** (recommended): The device sends data to Home Assistant in real time (faster, lower latency). **Polling**: Home Assistant periodically asks the device for data (simple, no device changes needed)." + }, + "description": "Choose how the integration receives data from the device.", + "title": "Update mode" + }, + "reconfigure": { + "data": { + "host": "Hostname or IP address" + }, + "data_description": { + "host": "Enter the new IP address of your WiBeee device." + }, + "description": "Update the IP address of your WiBeee energy monitor.", + "title": "Reconfigure WiBeee device" + }, + "user": { + "data": { + "host": "Hostname or IP address" + }, + "data_description": { + "host": "The IP address of your WiBeee energy monitor." + }, + "description": "Enter the IP address of your WiBeee energy monitor. Make sure the device has a static IP or DHCP reservation.", + "title": "Add WiBeee device" + } + } + }, + "entity": { + "sensor": { + "active_energy": { "name": "Active Energy" }, + "active_power": { "name": "Active Power" }, + "angle": { "name": "Angle" }, + "apparent_power": { "name": "Apparent Power" }, + "capacitive_reactive_energy": { "name": "Capacitive Reactive Energy" }, + "capacitive_reactive_power": { "name": "Capacitive Reactive Power" }, + "current": { "name": "Current" }, + "frequency": { "name": "Frequency" }, + "inductive_reactive_energy": { "name": "Inductive Reactive Energy" }, + "inductive_reactive_power": { "name": "Inductive Reactive Power" }, + "phase_voltage": { "name": "Phase Voltage" }, + "power_factor": { "name": "Power Factor" }, + "thd_current": { "name": "THD Current" }, + "thd_current_fundamental": { "name": "THD Current Fundamental" }, + "thd_current_harmonic_3": { "name": "THD Current Harmonic 3" }, + "thd_current_harmonic_5": { "name": "THD Current Harmonic 5" }, + "thd_current_harmonic_7": { "name": "THD Current Harmonic 7" }, + "thd_current_harmonic_9": { "name": "THD Current Harmonic 9" }, + "thd_voltage": { "name": "THD Voltage" }, + "thd_voltage_fundamental": { "name": "THD Voltage Fundamental" }, + "thd_voltage_harmonic_3": { "name": "THD Voltage Harmonic 3" }, + "thd_voltage_harmonic_5": { "name": "THD Voltage Harmonic 5" }, + "thd_voltage_harmonic_7": { "name": "THD Voltage Harmonic 7" }, + "thd_voltage_harmonic_9": { "name": "THD Voltage Harmonic 9" } + } + }, + "options": { + "error": { + "auto_configure_failed": "Failed to auto-configure the device for Local Push. You can configure it manually via the device web interface." + }, + "step": { + "init": { + "data": { + "auto_configure": "Auto-configure device for Local Push", + "update_mode": "Update mode" + }, + "data_description": { + "auto_configure": "Automatically configure the WiBeee to send data to this Home Assistant instance.", + "update_mode": "**Local Push** (recommended): Device sends data to HA in real time. **Polling**: Periodically fetch data." + }, + "description": "Configure update settings", + "title": "WiBeee integration options" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 810338d54878db..e3f82ff44ac120 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -820,6 +820,7 @@ "wemo", "whirlpool", "whois", + "wibeee", "wiffi", "wiim", "wilight", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 700374cf8dac7d..abe8dcd53a11a7 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -1399,6 +1399,10 @@ "domain": "vicare", "macaddress": "B87424*", }, + { + "domain": "wibeee", + "macaddress": "001EC0*", + }, { "domain": "withings", "macaddress": "0024E4*", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 550fe74d22ae8c..41d9de49305e7a 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7855,6 +7855,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "wibeee": { + "name": "Wibeee Energy Monitor", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "wiffi": { "name": "Wiffi", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 6f1be3bc048f2a..e3f512dc4bd5d1 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5928,6 +5928,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.wibeee.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.withings.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 82051333d280ae..8de73effb89235 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2768,6 +2768,9 @@ pywebpush==2.3.0 # homeassistant.components.wemo pywemo==1.4.0 +# homeassistant.components.wibeee +pywibeee==0.1.3 + # homeassistant.components.wilight pywilight==0.0.74 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b24f3ab44db1a0..49a319841ebf81 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2364,6 +2364,9 @@ pywebpush==2.3.0 # homeassistant.components.wemo pywemo==1.4.0 +# homeassistant.components.wibeee +pywibeee==0.1.3 + # homeassistant.components.wilight pywilight==0.0.74 diff --git a/tests/components/wibeee/__init__.py b/tests/components/wibeee/__init__.py new file mode 100644 index 00000000000000..2ccfc768c991b4 --- /dev/null +++ b/tests/components/wibeee/__init__.py @@ -0,0 +1,3 @@ +"""Tests for the Wibeee integration.""" + +from __future__ import annotations diff --git a/tests/components/wibeee/conftest.py b/tests/components/wibeee/conftest.py new file mode 100644 index 00000000000000..fbcdc1009ca30b --- /dev/null +++ b/tests/components/wibeee/conftest.py @@ -0,0 +1,135 @@ +"""Test fixtures for Wibeee integration.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.wibeee.const import ( + CONF_MAC_ADDRESS, + CONF_UPDATE_MODE, + CONF_WIBEEE_ID, + DOMAIN, + MODE_LOCAL_PUSH, +) +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MOCK_HOST = "192.168.1.100" +MOCK_MAC = "001ec0112233" +MOCK_WIBEEE_ID = "WIBEEE" +MOCK_MODEL = "WBT" +MOCK_FIRMWARE = "4.4.199" + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Create a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=MOCK_MAC, + title="Wibeee 2233", + data={ + CONF_HOST: MOCK_HOST, + CONF_MAC_ADDRESS: MOCK_MAC, + CONF_WIBEEE_ID: MOCK_WIBEEE_ID, + }, + options={ + CONF_UPDATE_MODE: MODE_LOCAL_PUSH, + }, + version=2, + ) + + +@pytest.fixture(autouse=True) +def mock_wibeee_local_ip() -> Generator[AsyncMock]: + """Mock the network helpers used by the wibeee config flow.""" + with patch( + "homeassistant.components.wibeee.config_flow._get_local_ip", + new_callable=AsyncMock, + return_value="192.168.1.50", + ) as mock: + yield mock + + +@pytest.fixture(name="loaded_entry") +async def load_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_wibeee_api, +) -> MockConfigEntry: + """Set up the Wibeee integration in Home Assistant.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_config_entry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.wibeee.async_setup_entry", + return_value=True, + ) as mock_setup: + yield mock_setup + + +def _setup_mock_api(api: MagicMock) -> None: + """Configure common mock API behavior.""" + api.async_check_connection = AsyncMock(return_value=True) + api.async_fetch_device_info = AsyncMock( + return_value=MagicMock( + wibeee_id=MOCK_WIBEEE_ID, + mac_addr=MOCK_MAC, + mac_addr_formatted=MOCK_MAC.upper(), + mac_addr_short="2233", + model=MOCK_MODEL, + firmware_version=MOCK_FIRMWARE, + ip_addr=MOCK_HOST, + ) + ) + api.async_fetch_sensors_data = AsyncMock( + return_value={ + "fase1": {"vrms": "230.5", "p_activa": "277"}, + "fase4": {"vrms": "230.5", "p_activa": "277"}, + } + ) + api.async_configure_push_server = AsyncMock(return_value=True) + api.async_get_push_server_config = AsyncMock(return_value={"mac": MOCK_MAC}) + api.async_fetch_device_diagnostics = AsyncMock(return_value={"host": MOCK_HOST}) + api.async_fetch_status = AsyncMock( + return_value={"model": MOCK_MODEL, "webversion": MOCK_FIRMWARE} + ) + api.host = MOCK_HOST + + +@pytest.fixture(autouse=True) +def mock_wibeee_api() -> Generator[MagicMock]: + """Mock the WibeeeAPI class globally in all import locations.""" + api = MagicMock() + _setup_mock_api(api) + with ( + patch("pywibeee.WibeeeAPI", return_value=api) as mock_cls, + patch("homeassistant.components.wibeee.WibeeeAPI", return_value=api), + patch( + "homeassistant.components.wibeee.config_flow.WibeeeAPI", + return_value=api, + ), + patch( + "homeassistant.components.wibeee.coordinator.WibeeeAPI", + return_value=api, + ), + ): + mock_cls.return_value = api + yield api + + +@pytest.fixture +def mock_wibeee_api_config_flow(mock_wibeee_api) -> MagicMock: + """Mock for config flow (alias for mock_wibeee_api).""" + return mock_wibeee_api diff --git a/tests/components/wibeee/test_config_flow.py b/tests/components/wibeee/test_config_flow.py new file mode 100644 index 00000000000000..bfcb0e48e70733 --- /dev/null +++ b/tests/components/wibeee/test_config_flow.py @@ -0,0 +1,628 @@ +"""Tests for Wibeee config flow.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import aiohttp +import pytest + +from homeassistant import config_entries +from homeassistant.components.wibeee.config_flow import ( + _async_configure_device, + _get_ha_port, + _get_local_ip, + _get_local_ip_sync, + _is_routable_ip, +) +from homeassistant.components.wibeee.const import ( + CONF_AUTO_CONFIGURE, + CONF_UPDATE_MODE, + DOMAIN, + MODE_LOCAL_PUSH, + MODE_POLLING, +) +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo + +from .conftest import MOCK_HOST, MOCK_MAC + +from tests.common import MockConfigEntry + + +async def test_user_step_shows_form( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test that the user step shows a form with host input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + +async def test_user_step_validates_and_goes_to_mode( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_wibeee_api_config_flow: AsyncMock, +) -> None: + """Test user step validates device and moves to mode step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "mode" + + +async def test_user_step_connection_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_wibeee_api_config_flow: AsyncMock, +) -> None: + """Test user step handles connection error.""" + # validate_input calls async_fetch_device_info + mock_wibeee_api_config_flow.async_fetch_device_info.side_effect = TimeoutError( + "error" + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST}, + ) + + assert result["type"] is FlowResultType.FORM + assert "errors" in result + assert result["errors"][CONF_HOST] == "no_device_info" + + +async def test_user_step_invalid_device( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_wibeee_api_config_flow: AsyncMock, +) -> None: + """Test user step handles non-Wibeee device.""" + mock_wibeee_api_config_flow.async_fetch_device_info.return_value = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"][CONF_HOST] == "no_device_info" + + +async def test_dhcp_discovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_wibeee_api_config_flow: AsyncMock, +) -> None: + """Test DHCP discovery flow.""" + discovery_info = DhcpServiceInfo( + ip=MOCK_HOST, + macaddress=MOCK_MAC, + hostname="wibeee_test", + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=discovery_info, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "mode" + + +async def test_mode_step_creates_entry_polling( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_wibeee_api_config_flow: AsyncMock, +) -> None: + """Test mode step creates entry with polling mode.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_UPDATE_MODE: MODE_POLLING}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_HOST] == MOCK_HOST + assert result["options"][CONF_UPDATE_MODE] == MODE_POLLING + + +async def test_mode_step_creates_entry_push( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_wibeee_api_config_flow: AsyncMock, +) -> None: + """Test mode step creates entry with local push mode.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST}, + ) + + with patch( + "homeassistant.components.wibeee.config_flow._get_local_ip", + return_value="192.168.1.50", + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_UPDATE_MODE: MODE_LOCAL_PUSH, CONF_AUTO_CONFIGURE: False}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_HOST] == MOCK_HOST + assert result["options"][CONF_UPDATE_MODE] == MODE_LOCAL_PUSH + + +async def test_mode_step_auto_configure_fail( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_wibeee_api_config_flow: AsyncMock, +) -> None: + """Test mode step handles auto-configuration failure.""" + mock_wibeee_api_config_flow.async_configure_push_server.return_value = False + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST}, + ) + + with patch( + "homeassistant.components.wibeee.config_flow._get_local_ip", + return_value="192.168.1.50", + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_UPDATE_MODE: MODE_LOCAL_PUSH, CONF_AUTO_CONFIGURE: True}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "auto_configure_failed" + + +async def test_options_flow(hass: HomeAssistant, loaded_entry: MockConfigEntry) -> None: + """Test options flow.""" + + result = await hass.config_entries.options.async_init(loaded_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_UPDATE_MODE: MODE_POLLING, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert loaded_entry.options[CONF_UPDATE_MODE] == MODE_POLLING + + +async def test_options_flow_auto_configure_fail( + hass: HomeAssistant, + loaded_entry: MockConfigEntry, + mock_wibeee_api: AsyncMock, +) -> None: + """Test options flow handles auto-configuration failure.""" + # Ensure the instance used in options flow is mocked + mock_wibeee_api.async_configure_push_server.return_value = False + + result = await hass.config_entries.options.async_init(loaded_entry.entry_id) + + with patch( + "homeassistant.components.wibeee.config_flow._get_local_ip", + return_value="192.168.1.50", + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_UPDATE_MODE: MODE_LOCAL_PUSH, + CONF_AUTO_CONFIGURE: True, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "auto_configure_failed" + + +async def test_reconfigure_step_success( + hass: HomeAssistant, + loaded_entry: MockConfigEntry, + mock_wibeee_api_config_flow: AsyncMock, +) -> None: + """Test reconfigure step updates the host successfully.""" + new_host = "192.168.1.200" + + result = await loaded_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: new_host}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert loaded_entry.data[CONF_HOST] == new_host + + +async def test_reconfigure_step_wrong_device( + hass: HomeAssistant, + loaded_entry: MockConfigEntry, + mock_wibeee_api_config_flow: AsyncMock, +) -> None: + """Test reconfigure aborts when a different device is reached.""" + different_mac = "aabbccddeeff" + device_info = mock_wibeee_api_config_flow.async_fetch_device_info.return_value + device_info.mac_addr = different_mac + device_info.mac_addr_formatted = different_mac.upper() + + result = await loaded_entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.250"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_device" + + +async def test_reconfigure_step_no_device_info( + hass: HomeAssistant, + loaded_entry: MockConfigEntry, + mock_wibeee_api_config_flow: AsyncMock, +) -> None: + """Test reconfigure shows error when device cannot be reached.""" + mock_wibeee_api_config_flow.async_fetch_device_info.side_effect = TimeoutError( + "error" + ) + + result = await loaded_entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.250"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"][CONF_HOST] == "no_device_info" + + +# -- Helper function tests (no fixtures that mock the helpers) -- + + +@pytest.mark.parametrize( + ("ip", "expected"), + [ + ("192.168.1.1", True), + ("10.0.0.5", True), + ("8.8.8.8", True), + ("127.0.0.1", False), + ("169.254.1.1", False), + ("224.0.0.1", False), + ("0.0.0.0", False), + ("not-an-ip", False), + ("999.999.999.999", False), + ], +) +def test_is_routable_ip(ip: str, expected: bool) -> None: + """Test _is_routable_ip classifies addresses correctly.""" + assert _is_routable_ip(ip) is expected + + +def test_get_local_ip_sync_success() -> None: + """Test _get_local_ip_sync returns the socket-derived IP.""" + fake_sock = MagicMock() + fake_sock.getsockname.return_value = ("192.168.1.55", 12345) + with patch( + "homeassistant.components.wibeee.config_flow.socket.socket", + return_value=fake_sock, + ): + assert _get_local_ip_sync() == "192.168.1.55" + fake_sock.connect.assert_called_once() + fake_sock.close.assert_called_once() + + +def test_get_local_ip_sync_oserror_fallback() -> None: + """Test _get_local_ip_sync falls back to loopback on OSError.""" + fake_sock = MagicMock() + fake_sock.connect.side_effect = OSError("network unreachable") + with patch( + "homeassistant.components.wibeee.config_flow.socket.socket", + return_value=fake_sock, + ): + assert _get_local_ip_sync() == "127.0.0.1" + fake_sock.close.assert_called_once() + + +async def test_get_local_ip_uses_async_get_source_ip(hass: HomeAssistant) -> None: + """Test _get_local_ip returns the IP from network.async_get_source_ip.""" + with patch( + "homeassistant.components.wibeee.config_flow.async_get_source_ip", + new_callable=AsyncMock, + return_value="10.0.0.42", + ): + result = ( + await _get_local_ip.__wrapped__(hass) + if hasattr(_get_local_ip, "__wrapped__") + else await _get_local_ip(hass) + ) + assert result == "10.0.0.42" + + +async def test_get_local_ip_falls_back_to_get_url(hass: HomeAssistant) -> None: + """Test _get_local_ip falls back to get_url when async_get_source_ip fails.""" + with ( + patch( + "homeassistant.components.wibeee.config_flow.async_get_source_ip", + new_callable=AsyncMock, + side_effect=HomeAssistantError("no network"), + ), + patch( + "homeassistant.components.wibeee.config_flow.get_url", + return_value="http://192.168.1.77:8123", + ), + ): + result = await _get_local_ip(hass) + assert result == "192.168.1.77" + + +async def test_get_local_ip_get_url_returns_hostname(hass: HomeAssistant) -> None: + """Test _get_local_ip skips get_url result when it's a hostname (non-IP). + + Covers the ValueError branch when ipaddress.ip_address() rejects a non-IP + hostname like ``homeassistant.local``: the code falls through to the + socket-based executor fallback. + """ + with ( + patch( + "homeassistant.components.wibeee.config_flow.async_get_source_ip", + new_callable=AsyncMock, + side_effect=HomeAssistantError("no network"), + ), + patch( + "homeassistant.components.wibeee.config_flow.get_url", + return_value="http://homeassistant.local:8123", + ), + patch( + "homeassistant.components.wibeee.config_flow._get_local_ip_sync", + return_value="192.168.1.99", + ), + ): + result = await _get_local_ip(hass) + assert result == "192.168.1.99" + + +async def test_get_local_ip_uses_executor_fallback(hass: HomeAssistant) -> None: + """Test _get_local_ip falls back to socket-based detection.""" + with ( + patch( + "homeassistant.components.wibeee.config_flow.async_get_source_ip", + new_callable=AsyncMock, + side_effect=HomeAssistantError("no network"), + ), + patch( + "homeassistant.components.wibeee.config_flow.get_url", + side_effect=HomeAssistantError("no url"), + ), + patch( + "homeassistant.components.wibeee.config_flow._get_local_ip_sync", + return_value="192.168.1.99", + ), + ): + result = await _get_local_ip(hass) + assert result == "192.168.1.99" + + +async def test_get_ha_port_from_url(hass: HomeAssistant) -> None: + """Test _get_ha_port returns port from get_url.""" + with patch( + "homeassistant.components.wibeee.config_flow.get_url", + return_value="http://192.168.1.10:9999", + ): + assert _get_ha_port(hass) == 9999 + + +async def test_get_ha_port_default_on_error(hass: HomeAssistant) -> None: + """Test _get_ha_port returns default on HomeAssistantError.""" + with patch( + "homeassistant.components.wibeee.config_flow.get_url", + side_effect=HomeAssistantError("boom"), + ): + assert _get_ha_port(hass) == 8123 + + +async def test_get_ha_port_default_when_url_has_no_port(hass: HomeAssistant) -> None: + """Test _get_ha_port returns default when URL parses without a port.""" + with patch( + "homeassistant.components.wibeee.config_flow.get_url", + return_value="http://homeassistant.local", + ): + assert _get_ha_port(hass) == 8123 + + +async def test_async_configure_device_non_routable_ip(hass: HomeAssistant) -> None: + """Test _async_configure_device returns False for non-routable local IP.""" + with patch( + "homeassistant.components.wibeee.config_flow._get_local_ip", + new_callable=AsyncMock, + return_value="127.0.0.1", + ): + assert await _async_configure_device(hass, "192.168.1.100") is False + + +async def test_async_configure_device_timeout( + hass: HomeAssistant, mock_wibeee_api: MagicMock +) -> None: + """Test _async_configure_device returns False when API times out.""" + mock_wibeee_api.async_configure_push_server.side_effect = TimeoutError("t") + with patch( + "homeassistant.components.wibeee.config_flow._get_local_ip", + new_callable=AsyncMock, + return_value="192.168.1.50", + ): + assert await _async_configure_device(hass, "192.168.1.100") is False + + +async def test_async_configure_device_success( + hass: HomeAssistant, mock_wibeee_api: MagicMock +) -> None: + """Test _async_configure_device returns True on success.""" + mock_wibeee_api.async_configure_push_server.return_value = True + with patch( + "homeassistant.components.wibeee.config_flow._get_local_ip", + new_callable=AsyncMock, + return_value="192.168.1.50", + ): + assert await _async_configure_device(hass, "192.168.1.100") is True + + +# -- DHCP and exception-path tests -- + + +async def test_dhcp_already_configured_updates_host( + hass: HomeAssistant, + mock_wibeee_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test DHCP discovery aborts when device is already configured.""" + mock_config_entry.add_to_hass(hass) + + discovery_info = DhcpServiceInfo( + ip="192.168.1.250", + macaddress=MOCK_MAC, + hostname="wibeee_test", + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=discovery_info, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_dhcp_not_wibeee_device( + hass: HomeAssistant, mock_wibeee_api: MagicMock +) -> None: + """Test DHCP discovery aborts when device is not a Wibeee.""" + mock_wibeee_api.async_check_connection.return_value = False + discovery_info = DhcpServiceInfo( + ip=MOCK_HOST, + macaddress=MOCK_MAC, + hostname="not_wibeee", + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=discovery_info, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "not_wibeee_device" + + +async def test_dhcp_connection_error( + hass: HomeAssistant, mock_wibeee_api: MagicMock +) -> None: + """Test DHCP discovery aborts when connection fails.""" + mock_wibeee_api.async_check_connection.side_effect = aiohttp.ClientError("boom") + discovery_info = DhcpServiceInfo( + ip=MOCK_HOST, + macaddress=MOCK_MAC, + hostname="wibeee_test", + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=discovery_info, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "not_wibeee_device" + + +async def test_user_step_already_configured( + hass: HomeAssistant, + mock_wibeee_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test user step aborts when device is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_user_step_unexpected_exception( + hass: HomeAssistant, mock_wibeee_api: MagicMock +) -> None: + """Test user step shows generic error on unexpected exception.""" + mock_wibeee_api.async_fetch_device_info.side_effect = RuntimeError("boom") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "unknown" + + +async def test_reconfigure_step_unexpected_exception( + hass: HomeAssistant, + loaded_entry: MockConfigEntry, + mock_wibeee_api: MagicMock, +) -> None: + """Test reconfigure step shows generic error on unexpected exception.""" + mock_wibeee_api.async_fetch_device_info.side_effect = RuntimeError("boom") + + result = await loaded_entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.250"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "unknown" diff --git a/tests/components/wibeee/test_coordinator.py b/tests/components/wibeee/test_coordinator.py new file mode 100644 index 00000000000000..c371b27749f485 --- /dev/null +++ b/tests/components/wibeee/test_coordinator.py @@ -0,0 +1,132 @@ +"""Tests for Wibeee coordinator.""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import AsyncMock +from xml.etree.ElementTree import ParseError as XMLParseError + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.wibeee.coordinator import WibeeeCoordinator +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import UpdateFailed +import homeassistant.util.dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_coordinator_update_failed( + hass: HomeAssistant, mock_wibeee_api: AsyncMock +) -> None: + """Test coordinator update failure.""" + coordinator = WibeeeCoordinator(hass, mock_wibeee_api, config_entry=AsyncMock()) + # Must be an exception that the coordinator catches (TimeoutError, ClientError, etc) + mock_wibeee_api.async_fetch_sensors_data.side_effect = TimeoutError("Fetch failed") + + with pytest.raises(UpdateFailed): + await coordinator._async_update_data() + + +async def test_coordinator_xml_parse_error( + hass: HomeAssistant, mock_wibeee_api: AsyncMock +) -> None: + """Test coordinator translates XMLParseError into UpdateFailed.""" + coordinator = WibeeeCoordinator(hass, mock_wibeee_api, config_entry=AsyncMock()) + mock_wibeee_api.async_fetch_sensors_data.side_effect = XMLParseError("bad xml") + + with pytest.raises(UpdateFailed): + await coordinator._async_update_data() + + +async def test_coordinator_no_data( + hass: HomeAssistant, mock_wibeee_api: AsyncMock +) -> None: + """Test coordinator handles no data received.""" + coordinator = WibeeeCoordinator(hass, mock_wibeee_api, config_entry=AsyncMock()) + mock_wibeee_api.async_fetch_sensors_data.return_value = None + + with pytest.raises(UpdateFailed): + await coordinator._async_update_data() + + +async def test_coordinator_push_update_invalid( + hass: HomeAssistant, mock_wibeee_api: AsyncMock +) -> None: + """Test coordinator handles invalid push update data.""" + coordinator = WibeeeCoordinator(hass, mock_wibeee_api, config_entry=AsyncMock()) + + # Push non-dict data should be ignored + coordinator.async_push_update("not_a_dict") # type: ignore[arg-type] + assert coordinator.data is None + + +async def test_coordinator_push_staleness_watchdog( + hass: HomeAssistant, + loaded_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test push-mode coordinator marks data stale after timeout.""" + coordinator = loaded_entry.runtime_data.coordinator + assert coordinator.last_update_success is True + + # No further pushes; advance past the staleness window. + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done() + + assert coordinator.last_update_success is False + + +async def test_coordinator_push_resets_watchdog( + hass: HomeAssistant, + loaded_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that a fresh push update resets the staleness watchdog.""" + coordinator = loaded_entry.runtime_data.coordinator + + # Almost stale, then a push arrives -> watchdog reset. + freezer.tick(timedelta(minutes=4)) + coordinator.async_push_update({"fase4": {"vrms": "230.0"}}) + await hass.async_block_till_done() + assert coordinator.last_update_success is True + + # Advance another 4 minutes (total 8) -> still under the window since reset. + freezer.tick(timedelta(minutes=4)) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done() + assert coordinator.last_update_success is True + + +async def test_coordinator_shutdown_cancels_watchdog( + hass: HomeAssistant, + loaded_entry: MockConfigEntry, +) -> None: + """Test unloading a push-mode entry cancels the staleness watchdog.""" + assert await hass.config_entries.async_unload(loaded_entry.entry_id) + await hass.async_block_till_done() + assert loaded_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_coordinator_push_update_without_stale_after( + hass: HomeAssistant, mock_wibeee_api: AsyncMock +) -> None: + """Test push update on a coordinator without stale_after (polling mode). + + Covers the early-return branch in ``_reschedule_staleness_check`` when + ``_stale_after`` is None (e.g. polling-mode coordinators). + """ + coordinator = WibeeeCoordinator( + hass, + mock_wibeee_api, + config_entry=AsyncMock(), + update_interval=timedelta(seconds=30), + # stale_after omitted -> None + ) + coordinator.async_push_update({"fase4": {"vrms": "230.0"}}) + # Watchdog must NOT be armed. + assert coordinator._stale_unsub is None + assert coordinator.data == {"fase4": {"vrms": "230.0"}} diff --git a/tests/components/wibeee/test_diagnostics.py b/tests/components/wibeee/test_diagnostics.py new file mode 100644 index 00000000000000..b460c2103a4f07 --- /dev/null +++ b/tests/components/wibeee/test_diagnostics.py @@ -0,0 +1,52 @@ +"""Tests for Wibeee diagnostics.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from homeassistant.components.diagnostics import REDACTED +from homeassistant.components.wibeee.diagnostics import ( + _redact_coordinator_data, + async_get_config_entry_diagnostics, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_diagnostics( + hass: HomeAssistant, + loaded_entry: MockConfigEntry, + mock_wibeee_api: AsyncMock, +) -> None: + """Test diagnostics.""" + mock_wibeee_api.async_get_push_server_config.return_value = { + "mac": "00:11:22:33:44:55" + } + mock_wibeee_api.async_fetch_device_diagnostics.return_value = {"host": "1.2.3.4"} + + diag = await async_get_config_entry_diagnostics(hass, loaded_entry) + + assert diag["device"]["mac_addr"] == REDACTED + assert diag["device_config"]["host"] == REDACTED + assert diag["push_server_config"]["mac"] == REDACTED + + +async def test_diagnostics_error( + hass: HomeAssistant, + loaded_entry: MockConfigEntry, + mock_wibeee_api: AsyncMock, +) -> None: + """Test diagnostics handles API errors.""" + mock_wibeee_api.async_get_push_server_config.side_effect = Exception("API Error") + mock_wibeee_api.async_fetch_device_diagnostics.side_effect = Exception("API Error") + + diag = await async_get_config_entry_diagnostics(hass, loaded_entry) + + assert "error" in diag["push_server_config"] + assert "error" in diag["device_config"] + + +def test_redact_coordinator_data_none() -> None: + """Test _redact_coordinator_data returns None for None input.""" + assert _redact_coordinator_data(None) is None diff --git a/tests/components/wibeee/test_init.py b/tests/components/wibeee/test_init.py new file mode 100644 index 00000000000000..c7d153515fdaba --- /dev/null +++ b/tests/components/wibeee/test_init.py @@ -0,0 +1,182 @@ +"""Tests for Wibeee integration setup.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import aiohttp + +from homeassistant import config_entries +from homeassistant.components.wibeee.const import ( + CONF_MAC_ADDRESS, + CONF_UPDATE_MODE, + CONF_WIBEEE_ID, + DOMAIN, + MODE_LOCAL_PUSH, + MODE_POLLING, +) +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_flow_init(hass: HomeAssistant) -> None: + """Test that the flow is initialized.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + +async def test_config_entry_loaded(loaded_entry: ConfigEntry) -> None: + """Test that config entry is loaded.""" + assert loaded_entry.state is ConfigEntryState.LOADED + + +async def test_setup_entry_connection_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_wibeee_api: MagicMock, +) -> None: + """Test setup raises ConfigEntryNotReady on connection error.""" + mock_wibeee_api.async_fetch_device_info.side_effect = aiohttp.ClientError("boom") + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_device_info_none_uses_fallback( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_wibeee_api: MagicMock, +) -> None: + """Test setup uses fallback device info when API returns None.""" + # Force polling mode so we don't need push receiver IP resolution + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + mock_config_entry, options={CONF_UPDATE_MODE: MODE_POLLING} + ) + mock_wibeee_api.async_fetch_device_info.return_value = None + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_setup_entry_push_mode_initial_data_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_wibeee_api: MagicMock, +) -> None: + """Test push mode raises ConfigEntryNotReady when initial fetch fails.""" + mock_wibeee_api.async_fetch_sensors_data.side_effect = TimeoutError("timeout") + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_push_mode_no_initial_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_wibeee_api: MagicMock, +) -> None: + """Test push mode raises ConfigEntryNotReady when initial data is empty.""" + mock_wibeee_api.async_fetch_sensors_data.return_value = {} + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_push_mode_resolves_hostname( + hass: HomeAssistant, + mock_wibeee_api: MagicMock, +) -> None: + """Test push mode resolves hostname to IP via gethostbyname.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="001ec0112233", + title="Wibeee 2233", + data={ + CONF_HOST: "wibeee.local", + CONF_MAC_ADDRESS: "001ec0112233", + CONF_WIBEEE_ID: "WIBEEE", + }, + options={CONF_UPDATE_MODE: MODE_LOCAL_PUSH}, + version=2, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.wibeee.socket.gethostbyname", + return_value="192.168.1.123", + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + + +async def test_setup_entry_push_mode_hostname_resolution_fails( + hass: HomeAssistant, + mock_wibeee_api: MagicMock, +) -> None: + """Test push mode raises ConfigEntryNotReady when hostname cannot be resolved.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="001ec0112233", + title="Wibeee 2233", + data={ + CONF_HOST: "invalid-hostname", + CONF_MAC_ADDRESS: "001ec0112233", + CONF_WIBEEE_ID: "WIBEEE", + }, + options={CONF_UPDATE_MODE: MODE_LOCAL_PUSH}, + version=2, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.wibeee.socket.gethostbyname", + side_effect=OSError("name resolution failed"), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_unload_entry( + hass: HomeAssistant, + loaded_entry: MockConfigEntry, +) -> None: + """Test that unloading works.""" + assert await hass.config_entries.async_unload(loaded_entry.entry_id) + await hass.async_block_till_done() + assert loaded_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_options_update_reloads_entry( + hass: HomeAssistant, + loaded_entry: MockConfigEntry, +) -> None: + """Test that updating options reloads the entry.""" + hass.config_entries.async_update_entry( + loaded_entry, options={CONF_UPDATE_MODE: MODE_POLLING} + ) + await hass.async_block_till_done() + assert loaded_entry.state is ConfigEntryState.LOADED + assert loaded_entry.options[CONF_UPDATE_MODE] == MODE_POLLING diff --git a/tests/components/wibeee/test_push_receiver.py b/tests/components/wibeee/test_push_receiver.py new file mode 100644 index 00000000000000..63926c47247f99 --- /dev/null +++ b/tests/components/wibeee/test_push_receiver.py @@ -0,0 +1,358 @@ +"""Tests for Wibeee push receiver.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from homeassistant.components.wibeee.push_receiver import ( + PushReceiver, + WibeeeReceiverAvgView, + WibeeeReceiverLeapView, + WibeeeReceiverView, + _dispatch_push_data, + _handle_push_request, + parse_push_data, +) + +# --------------------------------------------------------------------------- +# Test fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def push_receiver() -> PushReceiver: + """Create a PushReceiver instance.""" + return PushReceiver() + + +@pytest.fixture +def registered_receiver( + push_receiver: PushReceiver, +) -> tuple[PushReceiver, list[dict[str, Any]]]: + """Create a PushReceiver with a registered device.""" + calls: list[dict[str, Any]] = [] + + def listener(data: dict[str, Any]) -> None: + calls.append(data) + + push_receiver.register_device("001ec0112232", "192.168.1.100", listener) + return push_receiver, calls + + +# --------------------------------------------------------------------------- +# Test helpers +# --------------------------------------------------------------------------- + + +class MockRequest: + """Mock Request for testing.""" + + def __init__(self, query: dict[str, str], remote: str = "192.168.1.100") -> None: + """Initialize mock request.""" + self._query = query + self.remote = remote + + @property + def query(self) -> dict[str, str]: + """Return query dict.""" + return self._query + + +# --------------------------------------------------------------------------- +# Tests: parse_push_data +# --------------------------------------------------------------------------- + + +def test_parse_push_data_basic() -> None: + """Test basic parsing of push data.""" + query = { + "v1": "230.5", + "a1": "277", + "vt": "230.5", # total + } + + result = parse_push_data(query) + + assert "fase1" in result + assert "fase4" in result # total + + assert result["fase1"]["vrms"] == "230.5" + assert result["fase1"]["p_activa"] == "277" + assert result["fase4"]["vrms"] == "230.5" + + +def test_parse_push_data_three_phase() -> None: + """Test parsing of three-phase push data.""" + query = { + "v1": "230.0", + "v2": "231.0", + "v3": "229.0", + "vt": "230.0", + } + + result = parse_push_data(query) + + assert "fase1" in result + assert "fase2" in result + assert "fase3" in result + assert "fase4" in result + + +def test_parse_push_data_empty() -> None: + """Test parsing with empty query.""" + result = parse_push_data({}) + + assert result == {} + + +# --------------------------------------------------------------------------- +# Tests: _dispatch_push_data +# --------------------------------------------------------------------------- + + +def test_dispatch_push_data_valid( + registered_receiver: tuple[PushReceiver, list[dict[str, Any]]], +) -> None: + """Test dispatch with valid registered device.""" + receiver, calls = registered_receiver + + query = { + "mac": "001ec0112232", + "v1": "230.5", + } + + result = _dispatch_push_data(receiver, query) + + assert "device 001ec0112232" in result + assert len(calls) == 1 + + +def test_dispatch_unknown_mac(push_receiver: PushReceiver) -> None: + """Test dispatch with unknown MAC.""" + query = { + "mac": "deadbeef", + "v1": "230.5", + } + + result = _dispatch_push_data(push_receiver, query) + + assert "unregistered device" in result + + +def test_dispatch_missing_mac(push_receiver: PushReceiver) -> None: + """Test dispatch with missing MAC.""" + result = _dispatch_push_data(push_receiver, {}) + + assert result == "no MAC in push data" + + +# --------------------------------------------------------------------------- +# Tests: _handle_push_request +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_handle_push_request_ok( + registered_receiver: tuple[PushReceiver, list[dict[str, Any]]], +) -> None: + """Test HTTP handler with valid request.""" + receiver, calls = registered_receiver + + request = MockRequest( + { + "mac": "001ec0112232", + "v1": "230.5", + } + ) + + resp = await _handle_push_request(receiver, request, "<< None: + """Test HTTP handler with missing MAC.""" + request = MockRequest({}) + + resp = await _handle_push_request(push_receiver, request, "<< None: + """Test HTTP handler with unknown MAC.""" + request = MockRequest( + { + "mac": "deadbeef", + } + ) + + resp = await _handle_push_request(push_receiver, request, "<< None: + """Test HTTP handler with unauthorized source IP.""" + receiver, calls = registered_receiver + + request = MockRequest( + { + "mac": "001ec0112232", + "v1": "230.5", + }, + remote="192.168.1.200", # Different from registered IP + ) + + resp = await _handle_push_request(receiver, request, "<< None: + """Test registering a device.""" + receiver = PushReceiver() + calls: list[dict[str, Any]] = [] + + def listener(data: dict[str, Any]) -> None: + calls.append(data) + + receiver.register_device("001ec0112232", "192.168.1.100", listener) + + assert receiver.device_count == 1 + assert receiver.get_listener("001ec0112232") is not None + assert receiver.validate_ip("001ec0112232", "192.168.1.100") is True + assert receiver.validate_ip("001ec0112232", "192.168.1.200") is False + + +def test_push_receiver_unregister() -> None: + """Test unregistering a device.""" + receiver = PushReceiver() + calls: list[dict[str, Any]] = [] + + def listener(data: dict[str, Any]) -> None: + calls.append(data) + + receiver.register_device("001ec0112232", "192.168.1.100", listener) + receiver.unregister_device("001ec0112232") + + assert receiver.device_count == 0 + assert receiver.get_listener("001ec0112232") is None + + +def test_push_receiver_multiple_devices() -> None: + """Test registering multiple devices.""" + receiver = PushReceiver() + calls1: list[dict[str, Any]] = [] + calls2: list[dict[str, Any]] = [] + + def listener1(data: dict[str, Any]) -> None: + calls1.append(data) + + def listener2(data: dict[str, Any]) -> None: + calls2.append(data) + + receiver.register_device("001ec0112232", "192.168.1.100", listener1) + receiver.register_device("001ec0112233", "192.168.1.101", listener2) + + assert receiver.device_count == 2 + + +# --------------------------------------------------------------------------- +# Tests: PushReceiver.validate_ip edge cases +# --------------------------------------------------------------------------- + + +def test_validate_ip_remote_none() -> None: + """Test validate_ip rejects when remote_ip is None.""" + receiver = PushReceiver() + receiver.register_device("001ec0112232", "192.168.1.100", lambda d: None) + + assert receiver.validate_ip("001ec0112232", None) is False + + +def test_dispatch_no_recognized_sensors( + registered_receiver: tuple[PushReceiver, list[dict[str, Any]]], +) -> None: + """Test dispatch returns no-sensors message when query has only mac.""" + receiver, calls = registered_receiver + + # Query with mac but no recognized sensor params + query = {"mac": "001ec0112232", "junk": "xyz"} + result = _dispatch_push_data(receiver, query) + + assert "no recognized sensors" in result + assert calls == [] + + +def test_parse_push_data_skips_short_params() -> None: + """Test parse_push_data ignores params shorter than 2 chars.""" + # Single-char params can't have prefix+suffix → must be skipped. + query = {"x": "1", "v1": "230.0"} + + result = parse_push_data(query) + + # Only v1 should be parsed; the short "x" must not crash or appear. + assert "fase1" in result + assert result["fase1"]["vrms"] == "230.0" + + +# --------------------------------------------------------------------------- +# Tests: View classes (thin wrappers around _handle_push_request) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_receiver_avg_view_get( + registered_receiver: tuple[PushReceiver, list[dict[str, Any]]], +) -> None: + """Test WibeeeReceiverAvgView.get delegates to _handle_push_request.""" + receiver, _calls = registered_receiver + view = WibeeeReceiverAvgView(receiver) + request = MockRequest({"mac": "001ec0112232", "v1": "230.0"}) + + resp = await view.get(request) + + assert resp.status == 200 + + +@pytest.mark.asyncio +async def test_receiver_view_get( + registered_receiver: tuple[PushReceiver, list[dict[str, Any]]], +) -> None: + """Test WibeeeReceiverView.get delegates to _handle_push_request.""" + receiver, _calls = registered_receiver + view = WibeeeReceiverView(receiver) + request = MockRequest({"mac": "001ec0112232", "v1": "230.0"}) + + resp = await view.get(request) + + assert resp.status == 200 + + +@pytest.mark.asyncio +async def test_receiver_leap_view_get( + registered_receiver: tuple[PushReceiver, list[dict[str, Any]]], +) -> None: + """Test WibeeeReceiverLeapView.get delegates to _handle_push_request.""" + receiver, _calls = registered_receiver + view = WibeeeReceiverLeapView(receiver) + request = MockRequest({"mac": "001ec0112232", "v1": "230.0"}) + + resp = await view.get(request) + + assert resp.status == 200 diff --git a/tests/components/wibeee/test_sensor.py b/tests/components/wibeee/test_sensor.py new file mode 100644 index 00000000000000..a53f472098f107 --- /dev/null +++ b/tests/components/wibeee/test_sensor.py @@ -0,0 +1,269 @@ +"""Tests for Wibeee sensors.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from homeassistant.components.wibeee.const import ( + CONF_MAC_ADDRESS, + CONF_UPDATE_MODE, + CONF_WIBEEE_ID, + DOMAIN, + MODE_LOCAL_PUSH, + MODE_POLLING, +) +from homeassistant.const import CONF_HOST, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from .conftest import MOCK_HOST, MOCK_MAC, MOCK_WIBEEE_ID + +from tests.common import MockConfigEntry + + +async def test_sensors_created( + hass: HomeAssistant, loaded_entry: MockConfigEntry +) -> None: + """Test that sensor entities are created.""" + states = hass.states.async_all("sensor") + # Should have sensors for the discovered phases + entity_ids = {state.entity_id for state in states} + assert "sensor.wibeee_2233_active_power" in entity_ids + assert "sensor.wibeee_2233_l1_active_power" in entity_ids + + +async def test_sensor_state_class( + hass: HomeAssistant, loaded_entry: MockConfigEntry +) -> None: + """Test sensor has correct state class.""" + state = hass.states.get("sensor.wibeee_2233_active_power") + assert state.attributes.get("state_class") == "measurement" + + +async def test_sensor_no_data( + hass: HomeAssistant, loaded_entry: MockConfigEntry +) -> None: + """Test sensor handles missing data.""" + # Wipe coordinator data + runtime = loaded_entry.runtime_data + coordinator = runtime.coordinator + coordinator.async_set_updated_data(None) + await hass.async_block_till_done() + + state = hass.states.get("sensor.wibeee_2233_active_power") + assert state.state == STATE_UNAVAILABLE + + +async def test_sensor_invalid_value( + hass: HomeAssistant, loaded_entry: MockConfigEntry +) -> None: + """Test sensor handles non-numeric values.""" + runtime = loaded_entry.runtime_data + coordinator = runtime.coordinator + + # Inject non-numeric data + invalid_data = { + "fase4": { + "p_activa": "not_a_number", + } + } + coordinator.async_set_updated_data(invalid_data) + await hass.async_block_till_done() + + state = hass.states.get("sensor.wibeee_2233_active_power") + assert state.state == STATE_UNAVAILABLE + + +async def test_sensors_push_mode_filters_polling_only_keys( + hass: HomeAssistant, mock_wibeee_api: MagicMock +) -> None: + """Push mode must skip sensors whose keys cannot be refreshed via push.""" + # Initial fetch reports a polling-only metric (angle) plus a push-refreshable + # one (p_activa). In push mode, only the latter should produce an entity. + mock_wibeee_api.async_fetch_sensors_data.return_value = { + "fase4": {"p_activa": "120", "angle": "33"}, + } + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=MOCK_MAC, + title="Wibeee 2233", + data={ + CONF_HOST: MOCK_HOST, + CONF_MAC_ADDRESS: MOCK_MAC, + CONF_WIBEEE_ID: MOCK_WIBEEE_ID, + }, + options={CONF_UPDATE_MODE: MODE_LOCAL_PUSH}, + version=2, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("sensor.wibeee_2233_active_power") is not None + # angle is not push-refreshable, so the entity must not exist in push mode + assert hass.states.get("sensor.wibeee_2233_angle") is None + + +async def test_sensors_polling_mode_keeps_all_keys( + hass: HomeAssistant, mock_wibeee_api: MagicMock +) -> None: + """Polling mode keeps all sensors, including polling-only metrics.""" + mock_wibeee_api.async_fetch_sensors_data.return_value = { + "fase4": {"p_activa": "120", "angle": "33"}, + } + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=MOCK_MAC, + title="Wibeee 2233", + data={ + CONF_HOST: MOCK_HOST, + CONF_MAC_ADDRESS: MOCK_MAC, + CONF_WIBEEE_ID: MOCK_WIBEEE_ID, + }, + options={CONF_UPDATE_MODE: MODE_POLLING}, + version=2, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Both should exist; angle is disabled-by-default so check the entity registry + from homeassistant.helpers import entity_registry as er # noqa: PLC0415 + + registry = er.async_get(hass) + assert registry.async_get(f"sensor.wibeee_{MOCK_MAC[-4:]}_active_power") is not None + assert registry.async_get(f"sensor.wibeee_{MOCK_MAC[-4:]}_angle") is not None + + +async def test_sensor_setup_no_data_returns_early( + hass: HomeAssistant, +) -> None: + """sensor.async_setup_entry must return early when coordinator has no data.""" + from homeassistant.components.wibeee.sensor import ( # noqa: PLC0415 + async_setup_entry, + ) + + coordinator = MagicMock() + coordinator.data = None + runtime = MagicMock() + runtime.coordinator = coordinator + runtime.device_info = MagicMock(mac_addr_short="2233", ip_addr=MOCK_HOST) + entry = MagicMock() + entry.runtime_data = runtime + entry.options = {} + + added: list = [] + + def _add(entities, update_before_add=False) -> None: + added.extend(entities) + + await async_setup_entry(hass, entry, _add) + assert added == [] + + +async def test_sensor_setup_no_phases_returns_early( + hass: HomeAssistant, +) -> None: + """sensor.async_setup_entry must return early when no known phases found.""" + from homeassistant.components.wibeee.sensor import ( # noqa: PLC0415 + async_setup_entry, + ) + + coordinator = MagicMock() + # Data with only unknown phase keys → discovered_phases stays empty. + coordinator.data = {"unknown_phase": {"vrms": "230"}} + runtime = MagicMock() + runtime.coordinator = coordinator + runtime.device_info = MagicMock(mac_addr_short="2233", ip_addr=MOCK_HOST) + entry = MagicMock() + entry.runtime_data = runtime + entry.options = {} + + added: list = [] + + def _add(entities, update_before_add=False) -> None: + added.extend(entities) + + await async_setup_entry(hass, entry, _add) + assert added == [] + + +async def test_sensor_native_value_non_dict_data( + hass: HomeAssistant, loaded_entry: MockConfigEntry +) -> None: + """native_value returns None when coordinator.data is not a dict.""" + from homeassistant.components.wibeee.const import SENSOR_TYPES # noqa: PLC0415 + from homeassistant.components.wibeee.sensor import WibeeeSensor # noqa: PLC0415 + + runtime = loaded_entry.runtime_data + coordinator = runtime.coordinator + sensor = WibeeeSensor( + coordinator=coordinator, + device_info=runtime.device_info, + phase_key="fase4", + description=SENSOR_TYPES["p_activa"], + ) + + coordinator.data = "not_a_dict" # type: ignore[assignment] + assert sensor.native_value is None + + +async def test_sensor_native_value_phase_not_dict( + hass: HomeAssistant, loaded_entry: MockConfigEntry +) -> None: + """native_value returns None when phase data is not a dict.""" + from homeassistant.components.wibeee.const import SENSOR_TYPES # noqa: PLC0415 + from homeassistant.components.wibeee.sensor import WibeeeSensor # noqa: PLC0415 + + runtime = loaded_entry.runtime_data + coordinator = runtime.coordinator + sensor = WibeeeSensor( + coordinator=coordinator, + device_info=runtime.device_info, + phase_key="fase4", + description=SENSOR_TYPES["p_activa"], + ) + + coordinator.data = {"fase4": "garbage"} # type: ignore[dict-item] + assert sensor.native_value is None + + +async def test_sensor_native_value_missing_key( + hass: HomeAssistant, loaded_entry: MockConfigEntry +) -> None: + """native_value returns None when the sensor key is absent from phase.""" + from homeassistant.components.wibeee.const import SENSOR_TYPES # noqa: PLC0415 + from homeassistant.components.wibeee.sensor import WibeeeSensor # noqa: PLC0415 + + runtime = loaded_entry.runtime_data + coordinator = runtime.coordinator + sensor = WibeeeSensor( + coordinator=coordinator, + device_info=runtime.device_info, + phase_key="fase4", + description=SENSOR_TYPES["p_activa"], + ) + + coordinator.data = {"fase4": {"vrms": "230.0"}} + assert sensor.native_value is None + + +async def test_sensor_native_value_invalid_number( + hass: HomeAssistant, loaded_entry: MockConfigEntry +) -> None: + """native_value returns None when value can't be parsed as a float.""" + from homeassistant.components.wibeee.const import SENSOR_TYPES # noqa: PLC0415 + from homeassistant.components.wibeee.sensor import WibeeeSensor # noqa: PLC0415 + + runtime = loaded_entry.runtime_data + coordinator = runtime.coordinator + sensor = WibeeeSensor( + coordinator=coordinator, + device_info=runtime.device_info, + phase_key="fase4", + description=SENSOR_TYPES["p_activa"], + ) + + coordinator.data = {"fase4": {"p_activa": "not_a_number"}} + assert sensor.native_value is None