diff --git a/.strict-typing b/.strict-typing index 2a6a23707ea3dc..2d57c3a9d99c00 100644 --- a/.strict-typing +++ b/.strict-typing @@ -117,6 +117,7 @@ homeassistant.components.bang_olufsen.* homeassistant.components.bayesian.* homeassistant.components.binary_sensor.* homeassistant.components.bitcoin.* +homeassistant.components.bitvis.* homeassistant.components.blockchain.* homeassistant.components.blue_current.* homeassistant.components.blueprint.* diff --git a/CODEOWNERS b/CODEOWNERS index 21419e88ccea3a..32e19062e8dd69 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -231,6 +231,8 @@ CLAUDE.md @home-assistant/core /tests/components/bayesian/ @HarvsG /homeassistant/components/binary_sensor/ @home-assistant/core /tests/components/binary_sensor/ @home-assistant/core +/homeassistant/components/bitvis/ @MandusBorjesson @real-tintin @simontegelid +/tests/components/bitvis/ @MandusBorjesson @real-tintin @simontegelid /homeassistant/components/bizkaibus/ @UgaitzEtxebarria /homeassistant/components/blebox/ @bbx-a @swistakm @bkobus-bbx /tests/components/blebox/ @bbx-a @swistakm @bkobus-bbx diff --git a/homeassistant/components/bitvis/__init__.py b/homeassistant/components/bitvis/__init__.py new file mode 100644 index 00000000000000..9480b35af1325a --- /dev/null +++ b/homeassistant/components/bitvis/__init__.py @@ -0,0 +1,33 @@ +"""The Bitvis Power Hub integration.""" + +import logging + +from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.core import HomeAssistant + +from .coordinator import BitvisConfigEntry, BitvisDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +_PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: BitvisConfigEntry) -> bool: + """Set up Bitvis Power Hub from a config entry.""" + coordinator = BitvisDataUpdateCoordinator( + hass, entry, entry.data[CONF_HOST], entry.data[CONF_PORT] + ) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + entry.async_on_unload(coordinator.async_stop) + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: BitvisConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/bitvis/config_flow.py b/homeassistant/components/bitvis/config_flow.py new file mode 100644 index 00000000000000..4cb575cb65af24 --- /dev/null +++ b/homeassistant/components/bitvis/config_flow.py @@ -0,0 +1,137 @@ +"""Config flow for the Bitvis Power Hub integration.""" + +import logging +from typing import Any, override + +from bitvis_protobuf.utils import async_verify_udp_port_bindable, normalize_host +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN, MODEL_NAME +from .coordinator import async_get_listener_registry + +_LOGGER = logging.getLogger(__name__) + +_SINGLE_INSTANCE_UNIQUE_ID = DOMAIN + + +async def _async_test_port(hass: HomeAssistant, port: int) -> None: + """Verify the UDP port can be bound.""" + + if async_get_listener_registry(hass).has_listener(port): + return + + await async_verify_udp_port_bindable(port) + + +class BitvisConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Bitvis Power Hub.""" + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovery_info: ZeroconfServiceInfo | None = None + + def _get_friendly_name(self, name: str | None) -> str: + """Return a user-friendly name derived from the zeroconf name.""" + if not name: + return DEFAULT_NAME + instance = name.split(".", 1)[0] + return instance or DEFAULT_NAME + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + host = normalize_host(user_input[CONF_HOST]) + + await self.async_set_unique_id(_SINGLE_INSTANCE_UNIQUE_ID) + self._abort_if_unique_id_configured() + self._async_abort_entries_match({CONF_HOST: host}) + + try: + await _async_test_port(self.hass, DEFAULT_PORT) + except OSError: + errors["base"] = "cannot_connect" + else: + return self.async_create_entry( + title=MODEL_NAME, + data={ + CONF_HOST: host, + CONF_PORT: DEFAULT_PORT, + }, + ) + + data_schema = vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + } + ) + + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + ) + + @override + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + _LOGGER.debug("Discovered Bitvis Power Hub via Zeroconf: %s", discovery_info) + + host = discovery_info.host + + await self.async_set_unique_id(_SINGLE_INSTANCE_UNIQUE_ID) + self._abort_if_unique_id_configured() + self._async_abort_entries_match({CONF_HOST: host}) + + self._discovery_info = discovery_info + + # Show confirmation to user + self.context["title_placeholders"] = { + "name": self._get_friendly_name(discovery_info.name), + "host": host, + } + + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + if user_input is not None: + assert self._discovery_info is not None + host = self._discovery_info.host + + try: + await _async_test_port(self.hass, DEFAULT_PORT) + except OSError: + return self.async_abort(reason="cannot_connect") + + return self.async_create_entry( + title=self._get_friendly_name(self._discovery_info.name), + data={ + CONF_HOST: host, + CONF_PORT: DEFAULT_PORT, + }, + ) + + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={ + "name": self._get_friendly_name( + self._discovery_info.name if self._discovery_info else None + ), + "host": self._discovery_info.host if self._discovery_info else "", + }, + ) diff --git a/homeassistant/components/bitvis/const.py b/homeassistant/components/bitvis/const.py new file mode 100644 index 00000000000000..94ec246afd86db --- /dev/null +++ b/homeassistant/components/bitvis/const.py @@ -0,0 +1,19 @@ +"""Constants for the Bitvis Power Hub integration.""" + +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from .coordinator import BitvisListenerRegistry + +DOMAIN = "bitvis" +MANUFACTURER = "Bitvis" +MODEL_NAME = "Power Hub" + +ZEROCONF_SERVICE_TYPE = "_powerhub._udp.local." + +DEFAULT_NAME = "Bitvis Power Hub" +DEFAULT_PORT = 58220 + +DATA_LISTENER_REGISTRY: HassKey[BitvisListenerRegistry] = HassKey(DOMAIN) diff --git a/homeassistant/components/bitvis/coordinator.py b/homeassistant/components/bitvis/coordinator.py new file mode 100644 index 00000000000000..92b9100edad162 --- /dev/null +++ b/homeassistant/components/bitvis/coordinator.py @@ -0,0 +1,184 @@ +"""Data coordinator for Bitvis Power Hub.""" + +import asyncio +from dataclasses import dataclass +from datetime import datetime, timedelta +import logging +from typing import override + +from bitvis_protobuf.listener import SharedListener +from bitvis_protobuf.parse import PayloadDiagnostic, PayloadSample +from bitvis_protobuf.utils import async_resolve_host + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util +from homeassistant.util.variance import ignore_variance + +from .const import DATA_LISTENER_REGISTRY, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type BitvisConfigEntry = ConfigEntry[BitvisDataUpdateCoordinator] + + +def _uptime_to_boot_time(uptime_s: int) -> datetime: + """Convert uptime in seconds to an absolute boot datetime.""" + return dt_util.utcnow().replace(microsecond=0) - timedelta(seconds=uptime_s) + + +@dataclass(kw_only=True) +class BitvisData: + """Data structure for Bitvis measurements.""" + + sample: PayloadSample | None = None + diagnostic: PayloadDiagnostic | None = None + mac_address: str | None = None + model_name: str | None = None + sw_version: str | None = None + boot_time: datetime | None = None + + +class BitvisListenerRegistry: + """Registry that manages one shared UDP listener per port. + + Stored at hass.data[DATA_LISTENER_REGISTRY] so all coordinators can + look it up without duplicating state-management logic. + """ + + def __init__(self) -> None: + """Initialize registry storage.""" + self._listeners: dict[int, SharedListener] = {} + self._locks: dict[int, asyncio.Lock] = {} + + async def async_get_or_create(self, port: int) -> SharedListener: + """Return the listener for *port*, creating and starting it if needed.""" + port_lock = self._locks.setdefault(port, asyncio.Lock()) + async with port_lock: + if port not in self._listeners: + listener = SharedListener() + await listener.start(port) + self._listeners[port] = listener + return self._listeners[port] + + async def async_remove_if_unused(self, port: int) -> None: + """Stop and remove the listener for *port* when no coordinators remain.""" + port_lock = self._locks.setdefault(port, asyncio.Lock()) + async with port_lock: + listener = self._listeners.get(port) + if listener is None or not listener.is_empty: + return + await listener.stop() + del self._listeners[port] + + def get(self, port: int) -> SharedListener | None: + """Return an existing listener for *port*, or None.""" + return self._listeners.get(port) + + def has_listener(self, port: int) -> bool: + """Return True if a listener is already active on *port*.""" + return port in self._listeners + + +def async_get_listener_registry(hass: HomeAssistant) -> BitvisListenerRegistry: + """Return (creating if needed) the Bitvis listener registry for this HA instance.""" + if DATA_LISTENER_REGISTRY not in hass.data: + hass.data[DATA_LISTENER_REGISTRY] = BitvisListenerRegistry() + return hass.data[DATA_LISTENER_REGISTRY] + + +class BitvisDataUpdateCoordinator(DataUpdateCoordinator[BitvisData]): + """Coordinator to manage data updates from UDP packets.""" + + def __init__( + self, hass: HomeAssistant, config_entry: BitvisConfigEntry, host: str, port: int + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + config_entry=config_entry, + ) + self.host = host + self.port = port + self._registered_ips: set[str] = set() + self._stable_boot_time = ignore_variance( + _uptime_to_boot_time, timedelta(minutes=5) + ) + self.data = BitvisData() + + @override + async def _async_setup(self) -> None: + """Set up the coordinator by registering with the shared UDP listener.""" + try: + self._registered_ips = await async_resolve_host(self.host) + listener_registry = async_get_listener_registry(self.hass) + listener = await listener_registry.async_get_or_create(self.port) + listener.register(self._registered_ips, self._handle_payload) + except (OSError, ValueError) as err: + await self.async_stop() + raise UpdateFailed( + f"Failed to start UDP listener on port {self.port}" + ) from err + except RuntimeError as err: + await self.async_stop() + raise ConfigEntryError( + f"Failed to start UDP listener on port {self.port}" + ) from err + + async def async_stop(self) -> None: + """Unregister from the shared listener, stopping it when no longer needed.""" + if listener_registry := self.hass.data.get(DATA_LISTENER_REGISTRY): + if listener := listener_registry.get(self.port): + listener.unregister(self._registered_ips) + await listener_registry.async_remove_if_unused(self.port) + + self._registered_ips = set() + _LOGGER.debug( + "Unregistered coordinator from shared UDP listener for port %s", self.port + ) + + @callback + def _handle_payload( + self, + payload: PayloadSample | PayloadDiagnostic, + addr: tuple[str, int], + ) -> None: + """Handle a parsed payload dispatched by the shared listener.""" + _LOGGER.debug("Received payload from %s", addr) + if isinstance(payload, PayloadSample): + self._handle_sample(payload) + else: + self._handle_diagnostic(payload) + + @callback + def _handle_sample(self, payload: PayloadSample) -> None: + """Update sample data and notify listeners.""" + self.data.sample = payload + self.async_set_updated_data(self.data) + + @callback + def _handle_diagnostic(self, payload: PayloadDiagnostic) -> None: + """Update diagnostic data and notify listeners.""" + self.data.diagnostic = payload + diagnostic = payload.diagnostic + if diagnostic.HasField("device_info"): + device_info = diagnostic.device_info + self.data.mac_address = device_info.mac_address.hex(sep=":") + self.data.model_name = device_info.model_name + self.data.sw_version = device_info.sw_version + else: + self.data.mac_address = None + self.data.model_name = None + self.data.sw_version = None + self.data.boot_time = self._stable_boot_time(diagnostic.uptime_s) + + self.async_set_updated_data(self.data) + + @override + async def _async_update_data(self) -> BitvisData: + """Return current data (updates are push-based via UDP datagrams).""" + return self.data diff --git a/homeassistant/components/bitvis/manifest.json b/homeassistant/components/bitvis/manifest.json new file mode 100644 index 00000000000000..ce9af18dfe10af --- /dev/null +++ b/homeassistant/components/bitvis/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "bitvis", + "name": "Bitvis Power Hub", + "codeowners": ["@MandusBorjesson", "@real-tintin", "@simontegelid"], + "config_flow": true, + "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/bitvis", + "integration_type": "device", + "iot_class": "local_push", + "quality_scale": "bronze", + "requirements": ["bitvis-protobuf==0.2.7"], + "zeroconf": ["_powerhub._udp.local."] +} diff --git a/homeassistant/components/bitvis/quality_scale.yaml b/homeassistant/components/bitvis/quality_scale.yaml new file mode 100644 index 00000000000000..6f67935a45085a --- /dev/null +++ b/homeassistant/components/bitvis/quality_scale.yaml @@ -0,0 +1,74 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: + status: exempt + comment: "The integration does not poll." + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: "The integration does not have actions." + docs-conditions: + status: exempt + comment: "The integration does not have conditions." + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: "The integration does not have triggers." + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: "The integration does not have any service actions." + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: todo + parallel-updates: done + reauthentication-flow: + status: exempt + comment: "The integration does not use any credentials. It connects to a local device using only host and port via unauthenticated UDP push." + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: done diff --git a/homeassistant/components/bitvis/sensor.py b/homeassistant/components/bitvis/sensor.py new file mode 100644 index 00000000000000..91e0ada46a6660 --- /dev/null +++ b/homeassistant/components/bitvis/sensor.py @@ -0,0 +1,576 @@ +"""Sensor platform for Bitvis Power Hub.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from typing import TYPE_CHECKING, override + +from bitvis_protobuf.han_port_pb2 import HanPortSample +from bitvis_protobuf.powerhub_pb2 import Diagnostic + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, + UnitOfReactiveEnergy, + UnitOfReactivePower, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import BitvisConfigEntry +from .const import DOMAIN, MANUFACTURER, MODEL_NAME +from .coordinator import BitvisDataUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +def _build_device_info( + coordinator: BitvisDataUpdateCoordinator, + device_identifier: str, +) -> DeviceInfo: + """Build DeviceInfo shared by all Bitvis entities.""" + payload = coordinator.data.diagnostic + mac_address: str | None = None + model_name: str | None = None + sw_version: str | None = None + if payload is not None and payload.diagnostic.HasField("device_info"): + mac_address = coordinator.data.mac_address or None + model_name = payload.diagnostic.device_info.model_name or None + sw_version = payload.diagnostic.device_info.sw_version or None + return DeviceInfo( + identifiers={(DOMAIN, device_identifier)}, + connections={(CONNECTION_NETWORK_MAC, mac_address)} if mac_address else set(), + manufacturer=MANUFACTURER, + model=model_name or MODEL_NAME, + sw_version=sw_version, + ) + + +@dataclass(frozen=True, kw_only=True) +class BitvisSensorEntityDescription(SensorEntityDescription): + """Describes Bitvis sensor entity.""" + + value_fn: Callable[[HanPortSample], float | None] + + +@dataclass(frozen=True, kw_only=True) +class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): + """Describes Bitvis diagnostic sensor entity.""" + + value_fn: Callable[[Diagnostic], float | int | str | datetime | None] + + +SENSOR_DESCRIPTIONS: tuple[BitvisSensorEntityDescription, ...] = ( + # Phase voltages + BitvisSensorEntityDescription( + key="phase_voltage_l1", + translation_key="phase_voltage", + translation_placeholders={"phase": "L1"}, + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda data: ( + data.phase_voltage_l1_v if data.HasField("phase_voltage_l1_v") else None + ), + ), + BitvisSensorEntityDescription( + key="phase_voltage_l2", + translation_key="phase_voltage", + translation_placeholders={"phase": "L2"}, + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda data: ( + data.phase_voltage_l2_v if data.HasField("phase_voltage_l2_v") else None + ), + ), + BitvisSensorEntityDescription( + key="phase_voltage_l3", + translation_key="phase_voltage", + translation_placeholders={"phase": "L3"}, + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda data: ( + data.phase_voltage_l3_v if data.HasField("phase_voltage_l3_v") else None + ), + ), + # Phase currents + BitvisSensorEntityDescription( + key="phase_current_l1", + translation_key="phase_current", + translation_placeholders={"phase": "L1"}, + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + value_fn=lambda data: ( + data.phase_current_l1_a if data.HasField("phase_current_l1_a") else None + ), + ), + BitvisSensorEntityDescription( + key="phase_current_l2", + translation_key="phase_current", + translation_placeholders={"phase": "L2"}, + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + value_fn=lambda data: ( + data.phase_current_l2_a if data.HasField("phase_current_l2_a") else None + ), + ), + BitvisSensorEntityDescription( + key="phase_current_l3", + translation_key="phase_current", + translation_placeholders={"phase": "L3"}, + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + value_fn=lambda data: ( + data.phase_current_l3_a if data.HasField("phase_current_l3_a") else None + ), + ), + # Total active power + BitvisSensorEntityDescription( + key="power_active_delivered_to_client", + translation_key="power_active_import", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + value_fn=lambda data: ( + data.power_active_delivered_to_client_kw + if data.HasField("power_active_delivered_to_client_kw") + else None + ), + ), + BitvisSensorEntityDescription( + key="power_active_delivered_by_client", + translation_key="power_active_export", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + value_fn=lambda data: ( + data.power_active_delivered_by_client_kw + if data.HasField("power_active_delivered_by_client_kw") + else None + ), + ), + # Total reactive power + BitvisSensorEntityDescription( + key="power_reactive_delivered_to_client", + translation_key="power_reactive_import", + device_class=SensorDeviceClass.REACTIVE_POWER, + native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + value_fn=lambda data: ( + data.power_reactive_delivered_to_client_kvar + if data.HasField("power_reactive_delivered_to_client_kvar") + else None + ), + ), + BitvisSensorEntityDescription( + key="power_reactive_delivered_by_client", + translation_key="power_reactive_export", + device_class=SensorDeviceClass.REACTIVE_POWER, + native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + value_fn=lambda data: ( + data.power_reactive_delivered_by_client_kvar + if data.HasField("power_reactive_delivered_by_client_kvar") + else None + ), + ), + # Per-phase active power (to client) + BitvisSensorEntityDescription( + key="power_active_l1_delivered_to_client", + translation_key="power_active_phase_import", + translation_placeholders={"phase": "L1"}, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + value_fn=lambda data: ( + data.power_active_l1_delivered_to_client_kw + if data.HasField("power_active_l1_delivered_to_client_kw") + else None + ), + ), + BitvisSensorEntityDescription( + key="power_active_l2_delivered_to_client", + translation_key="power_active_phase_import", + translation_placeholders={"phase": "L2"}, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + value_fn=lambda data: ( + data.power_active_l2_delivered_to_client_kw + if data.HasField("power_active_l2_delivered_to_client_kw") + else None + ), + ), + BitvisSensorEntityDescription( + key="power_active_l3_delivered_to_client", + translation_key="power_active_phase_import", + translation_placeholders={"phase": "L3"}, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + value_fn=lambda data: ( + data.power_active_l3_delivered_to_client_kw + if data.HasField("power_active_l3_delivered_to_client_kw") + else None + ), + ), + # Per-phase active power (by client) + BitvisSensorEntityDescription( + key="power_active_l1_delivered_by_client", + translation_key="power_active_phase_export", + translation_placeholders={"phase": "L1"}, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + value_fn=lambda data: ( + data.power_active_l1_delivered_by_client_kw + if data.HasField("power_active_l1_delivered_by_client_kw") + else None + ), + ), + BitvisSensorEntityDescription( + key="power_active_l2_delivered_by_client", + translation_key="power_active_phase_import", + translation_placeholders={"phase": "L2"}, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + value_fn=lambda data: ( + data.power_active_l2_delivered_by_client_kw + if data.HasField("power_active_l2_delivered_by_client_kw") + else None + ), + ), + BitvisSensorEntityDescription( + key="power_active_l3_delivered_by_client", + translation_key="power_active_phase_export", + translation_placeholders={"phase": "L3"}, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + value_fn=lambda data: ( + data.power_active_l3_delivered_by_client_kw + if data.HasField("power_active_l3_delivered_by_client_kw") + else None + ), + ), + # Per-phase reactive power (to client) + BitvisSensorEntityDescription( + key="power_reactive_l1_delivered_to_client", + translation_key="power_reactive_phase_import", + translation_placeholders={"phase": "L1"}, + device_class=SensorDeviceClass.REACTIVE_POWER, + native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + value_fn=lambda data: ( + data.power_reactive_l1_delivered_to_client_kvar + if data.HasField("power_reactive_l1_delivered_to_client_kvar") + else None + ), + ), + BitvisSensorEntityDescription( + key="power_reactive_l2_delivered_to_client", + translation_key="power_reactive_phase_import", + translation_placeholders={"phase": "L2"}, + device_class=SensorDeviceClass.REACTIVE_POWER, + native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + value_fn=lambda data: ( + data.power_reactive_l2_delivered_to_client_kvar + if data.HasField("power_reactive_l2_delivered_to_client_kvar") + else None + ), + ), + BitvisSensorEntityDescription( + key="power_reactive_l3_delivered_to_client", + translation_key="power_reactive_phase_import", + translation_placeholders={"phase": "L3"}, + device_class=SensorDeviceClass.REACTIVE_POWER, + native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + value_fn=lambda data: ( + data.power_reactive_l3_delivered_to_client_kvar + if data.HasField("power_reactive_l3_delivered_to_client_kvar") + else None + ), + ), + # Per-phase reactive power (by client) + BitvisSensorEntityDescription( + key="power_reactive_l1_delivered_by_client", + translation_key="power_reactive_phase_export", + translation_placeholders={"phase": "L1"}, + device_class=SensorDeviceClass.REACTIVE_POWER, + native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + value_fn=lambda data: ( + data.power_reactive_l1_delivered_by_client_kvar + if data.HasField("power_reactive_l1_delivered_by_client_kvar") + else None + ), + ), + BitvisSensorEntityDescription( + key="power_reactive_l2_delivered_by_client", + translation_key="power_reactive_phase_export", + translation_placeholders={"phase": "L2"}, + device_class=SensorDeviceClass.REACTIVE_POWER, + native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + value_fn=lambda data: ( + data.power_reactive_l2_delivered_by_client_kvar + if data.HasField("power_reactive_l2_delivered_by_client_kvar") + else None + ), + ), + BitvisSensorEntityDescription( + key="power_reactive_l3_delivered_by_client", + translation_key="power_reactive_phase_export", + translation_placeholders={"phase": "L3"}, + device_class=SensorDeviceClass.REACTIVE_POWER, + native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + value_fn=lambda data: ( + data.power_reactive_l3_delivered_by_client_kvar + if data.HasField("power_reactive_l3_delivered_by_client_kvar") + else None + ), + ), + # Energy - active + BitvisSensorEntityDescription( + key="energy_active_delivered_to_client", + translation_key="energy_active_import", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + value_fn=lambda data: ( + data.energy_active_delivered_to_client_kwh + if data.HasField("energy_active_delivered_to_client_kwh") + else None + ), + ), + BitvisSensorEntityDescription( + key="energy_active_delivered_by_client", + translation_key="energy_active_export", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + value_fn=lambda data: ( + data.energy_active_delivered_by_client_kwh + if data.HasField("energy_active_delivered_by_client_kwh") + else None + ), + ), + # Energy - reactive + BitvisSensorEntityDescription( + key="energy_reactive_delivered_to_client", + translation_key="energy_reactive_import", + device_class=SensorDeviceClass.REACTIVE_ENERGY, + native_unit_of_measurement=UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + value_fn=lambda data: ( + data.energy_reactive_delivered_to_client_kvarh + if data.HasField("energy_reactive_delivered_to_client_kvarh") + else None + ), + ), + BitvisSensorEntityDescription( + key="energy_reactive_delivered_by_client", + translation_key="energy_reactive_export", + device_class=SensorDeviceClass.REACTIVE_ENERGY, + native_unit_of_measurement=UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=2, + value_fn=lambda data: ( + data.energy_reactive_delivered_by_client_kvarh + if data.HasField("energy_reactive_delivered_by_client_kvarh") + else None + ), + ), +) + +UPTIME_DESCRIPTION = SensorEntityDescription( + key="uptime", + device_class=SensorDeviceClass.UPTIME, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, +) + +DIAGNOSTIC_SENSOR_DESCRIPTIONS: tuple[BitvisDiagnosticSensorEntityDescription, ...] = ( + BitvisDiagnosticSensorEntityDescription( + key="wifi_rssi", + translation_key="wifi_rssi", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.wifi_rssi_dbm, + ), + BitvisDiagnosticSensorEntityDescription( + key="han_msg_successfully_parsed", + translation_key="han_msg_successfully_parsed", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.han_msg_successfully_parsed, + ), + BitvisDiagnosticSensorEntityDescription( + key="han_msg_buffer_overflow", + translation_key="han_msg_buffer_overflow", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.han_msg_buffer_overflow, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BitvisConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Bitvis sensor platform.""" + coordinator = entry.runtime_data + + entities: list[SensorEntity] = [ + BitvisSensorEntity(coordinator, description, entry) + for description in SENSOR_DESCRIPTIONS + ] + + entities.append(BitvisUptimeSensorEntity(coordinator, UPTIME_DESCRIPTION, entry)) + entities.extend( + BitvisDiagnosticSensorEntity(coordinator, description, entry) + for description in DIAGNOSTIC_SENSOR_DESCRIPTIONS + ) + + async_add_entities(entities) + + +class BitvisBaseSensorEntity( + CoordinatorEntity[BitvisDataUpdateCoordinator], SensorEntity +): + """Base class for Bitvis sensor entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: BitvisDataUpdateCoordinator, + description: SensorEntityDescription, + entry: BitvisConfigEntry, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + if TYPE_CHECKING: + assert entry.unique_id is not None + self._device_identifier = entry.unique_id + self._attr_unique_id = f"{self._device_identifier}_{description.key}" + + @property + @override + def device_info(self) -> DeviceInfo: + """Return device information.""" + return _build_device_info(self.coordinator, self._device_identifier) + + +class BitvisSensorEntity(BitvisBaseSensorEntity): + """Representation of a Bitvis sensor.""" + + entity_description: BitvisSensorEntityDescription + + @property + @override + def native_value(self) -> float | None: + """Return the state of the sensor.""" + payload = self.coordinator.data.sample + if TYPE_CHECKING: + assert payload is not None + return self.entity_description.value_fn(payload.sample) + + @property + @override + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.coordinator.data.sample is not None + + +class BitvisDiagnosticSensorEntity(BitvisBaseSensorEntity): + """Representation of a Bitvis diagnostic sensor.""" + + entity_description: BitvisDiagnosticSensorEntityDescription + + @property + @override + def native_value(self) -> float | int | str | datetime | None: + """Return the state of the sensor.""" + payload = self.coordinator.data.diagnostic + if TYPE_CHECKING: + assert payload is not None + return self.entity_description.value_fn(payload.diagnostic) + + @property + @override + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.coordinator.data.diagnostic is not None + + +class BitvisUptimeSensorEntity(BitvisBaseSensorEntity): + """Sensor entity for device uptime (boot time).""" + + entity_description: SensorEntityDescription + + @property + @override + def native_value(self) -> datetime | None: + """Return the stable boot time computed by the coordinator.""" + return self.coordinator.data.boot_time + + @property + @override + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.coordinator.data.boot_time is not None diff --git a/homeassistant/components/bitvis/strings.json b/homeassistant/components/bitvis/strings.json new file mode 100644 index 00000000000000..b9619717566f43 --- /dev/null +++ b/homeassistant/components/bitvis/strings.json @@ -0,0 +1,82 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Bitvis Power Hub device." + }, + "description": "Enter the network details for your Bitvis Power Hub device", + "title": "Set up Bitvis Power Hub" + }, + "zeroconf_confirm": { + "description": "Do you want to add the discovered Bitvis Power Hub ({name}) at {host} to Home Assistant?", + "title": "Discovered Bitvis Power Hub" + } + } + }, + "entity": { + "sensor": { + "energy_active_export": { + "name": "Active energy export" + }, + "energy_active_import": { + "name": "Active energy import" + }, + "energy_reactive_export": { + "name": "Reactive energy export" + }, + "energy_reactive_import": { + "name": "Reactive energy import" + }, + "han_msg_buffer_overflow": { + "name": "HAN buffer overflows" + }, + "han_msg_successfully_parsed": { + "name": "HAN messages successfully parsed" + }, + "phase_current": { + "name": "Current {phase}" + }, + "phase_voltage": { + "name": "Voltage {phase}" + }, + "power_active_export": { + "name": "Active power export" + }, + "power_active_import": { + "name": "Active power import" + }, + "power_active_phase_export": { + "name": "Active power export {phase}" + }, + "power_active_phase_import": { + "name": "Active power import {phase}" + }, + "power_reactive_export": { + "name": "Reactive power export" + }, + "power_reactive_import": { + "name": "Reactive power import" + }, + "power_reactive_phase_export": { + "name": "Reactive power export {phase}" + }, + "power_reactive_phase_import": { + "name": "Reactive power import {phase}" + }, + "wifi_rssi": { + "name": "Wi-Fi signal strength" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 2457db7e94ff24..179980845f229e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -101,6 +101,7 @@ "balboa", "bang_olufsen", "bayesian", + "bitvis", "blebox", "blink", "blue_current", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f1f93d8a69bcf7..9989b51e082b56 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -740,6 +740,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "bitvis": { + "name": "Bitvis Power Hub", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "bizkaibus": { "name": "Bizkaibus", "integration_type": "hub", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 46b601de0e9566..d9d363ff126a02 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -874,6 +874,11 @@ "domain": "plugwise", }, ], + "_powerhub._udp.local.": [ + { + "domain": "bitvis", + }, + ], "_powerview._tcp.local.": [ { "domain": "hunterdouglas_powerview", diff --git a/mypy.ini b/mypy.ini index 0d36aa56582a2f..80a41457ebec0c 100644 --- a/mypy.ini +++ b/mypy.ini @@ -926,6 +926,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.bitvis.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.blockchain.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 8a606819d4f70c..8977426f45b6b6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -653,6 +653,9 @@ batinfo==0.4.2 # homeassistant.components.scrape beautifulsoup4==4.13.3 +# homeassistant.components.bitvis +bitvis-protobuf==0.2.7 + # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/tests/components/bitvis/__init__.py b/tests/components/bitvis/__init__.py new file mode 100644 index 00000000000000..be92a5895396f9 --- /dev/null +++ b/tests/components/bitvis/__init__.py @@ -0,0 +1 @@ +"""Tests for the Bitvis Power Hub integration.""" diff --git a/tests/components/bitvis/conftest.py b/tests/components/bitvis/conftest.py new file mode 100644 index 00000000000000..debba320256363 --- /dev/null +++ b/tests/components/bitvis/conftest.py @@ -0,0 +1,50 @@ +"""Common fixtures for Bitvis Power Hub tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.bitvis.const import DOMAIN, MODEL_NAME +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +TEST_DEVICE_MAC = "aa:bb:cc:dd:ee:ff" + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.100", CONF_PORT: 5000}, + unique_id=TEST_DEVICE_MAC, + title=MODEL_NAME, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.bitvis.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> MockConfigEntry: + """Set up the integration with a mocked UDP listener.""" + mock_config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.bitvis.coordinator.BitvisDataUpdateCoordinator._async_setup", + new_callable=AsyncMock, + return_value=None, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_config_entry diff --git a/tests/components/bitvis/snapshots/test_sensor.ambr b/tests/components/bitvis/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..61a02a746ad9c2 --- /dev/null +++ b/tests/components/bitvis/snapshots/test_sensor.ambr @@ -0,0 +1,1721 @@ +# serializer version: 1 +# name: test_all_entities[sensor.power_hub_active_energy_export-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_hub_active_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Active energy export', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active energy export', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_active_export', + 'unique_id': 'aa:bb:cc:dd:ee:ff_energy_active_delivered_by_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.power_hub_active_energy_export-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Power Hub Active energy export', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.power_hub_active_energy_export', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '789.119995117188', + }) +# --- +# name: test_all_entities[sensor.power_hub_active_energy_import-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_hub_active_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Active energy import', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active energy import', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_active_import', + 'unique_id': 'aa:bb:cc:dd:ee:ff_energy_active_delivered_to_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.power_hub_active_energy_import-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Power Hub Active energy import', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.power_hub_active_energy_import', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1234.56005859375', + }) +# --- +# name: test_all_entities[sensor.power_hub_active_power_export-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_hub_active_power_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Active power export', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power export', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_active_export', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_delivered_by_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.power_hub_active_power_export-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Power Hub Active power export', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.power_hub_active_power_export', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[sensor.power_hub_active_power_export_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_hub_active_power_export_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Active power export L1', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power export L1', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_active_phase_export', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_l1_delivered_by_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.power_hub_active_power_export_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Power Hub Active power export L1', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.power_hub_active_power_export_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[sensor.power_hub_active_power_export_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_hub_active_power_export_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Active power export L3', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power export L3', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_active_phase_export', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_l3_delivered_by_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.power_hub_active_power_export_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Power Hub Active power export L3', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.power_hub_active_power_export_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[sensor.power_hub_active_power_import-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_hub_active_power_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Active power import', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power import', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_active_import', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_delivered_to_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.power_hub_active_power_import-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Power Hub Active power import', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.power_hub_active_power_import', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.41499996185303', + }) +# --- +# name: test_all_entities[sensor.power_hub_active_power_import_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_hub_active_power_import_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Active power import L1', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power import L1', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_active_phase_import', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_l1_delivered_to_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.power_hub_active_power_import_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Power Hub Active power import L1', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.power_hub_active_power_import_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.800000011920929', + }) +# --- +# name: test_all_entities[sensor.power_hub_active_power_import_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_hub_active_power_import_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Active power import L2', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power import L2', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_active_phase_import', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_l2_delivered_to_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.power_hub_active_power_import_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Power Hub Active power import L2', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.power_hub_active_power_import_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.699999988079071', + }) +# --- +# name: test_all_entities[sensor.power_hub_active_power_import_l2_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_hub_active_power_import_l2_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Active power import L2', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power import L2', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_active_phase_import', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_l2_delivered_by_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.power_hub_active_power_import_l2_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Power Hub Active power import L2', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.power_hub_active_power_import_l2_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[sensor.power_hub_active_power_import_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_hub_active_power_import_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Active power import L3', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power import L3', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_active_phase_import', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_l3_delivered_to_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.power_hub_active_power_import_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Power Hub Active power import L3', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.power_hub_active_power_import_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.915000021457672', + }) +# --- +# name: test_all_entities[sensor.power_hub_current_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_hub_current_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current L1', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current L1', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_current', + 'unique_id': 'aa:bb:cc:dd:ee:ff_phase_current_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.power_hub_current_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'Power Hub Current L1', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.power_hub_current_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.5', + }) +# --- +# name: test_all_entities[sensor.power_hub_current_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_hub_current_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current L2', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current L2', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_current', + 'unique_id': 'aa:bb:cc:dd:ee:ff_phase_current_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.power_hub_current_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'Power Hub Current L2', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.power_hub_current_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.30000019073486', + }) +# --- +# name: test_all_entities[sensor.power_hub_current_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_hub_current_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current L3', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current L3', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_current', + 'unique_id': 'aa:bb:cc:dd:ee:ff_phase_current_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.power_hub_current_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'Power Hub Current L3', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.power_hub_current_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.1000003814697', + }) +# --- +# name: test_all_entities[sensor.power_hub_han_buffer_overflows-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.power_hub_han_buffer_overflows', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'HAN buffer overflows', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HAN buffer overflows', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'han_msg_buffer_overflow', + 'unique_id': 'aa:bb:cc:dd:ee:ff_han_msg_buffer_overflow', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.power_hub_han_buffer_overflows-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Power Hub HAN buffer overflows', + : , + }), + 'context': , + 'entity_id': 'sensor.power_hub_han_buffer_overflows', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[sensor.power_hub_han_messages_successfully_parsed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.power_hub_han_messages_successfully_parsed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'HAN messages successfully parsed', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HAN messages successfully parsed', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'han_msg_successfully_parsed', + 'unique_id': 'aa:bb:cc:dd:ee:ff_han_msg_successfully_parsed', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.power_hub_han_messages_successfully_parsed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Power Hub HAN messages successfully parsed', + : , + }), + 'context': , + 'entity_id': 'sensor.power_hub_han_messages_successfully_parsed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1000', + }) +# --- +# name: test_all_entities[sensor.power_hub_reactive_energy_export-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_hub_reactive_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reactive energy export', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive energy export', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_reactive_export', + 'unique_id': 'aa:bb:cc:dd:ee:ff_energy_reactive_delivered_by_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.power_hub_reactive_energy_export-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'reactive_energy', + : 'Power Hub Reactive energy export', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.power_hub_reactive_energy_export', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23.4500007629395', + }) +# --- +# name: test_all_entities[sensor.power_hub_reactive_energy_import-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_hub_reactive_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reactive energy import', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive energy import', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_reactive_import', + 'unique_id': 'aa:bb:cc:dd:ee:ff_energy_reactive_delivered_to_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.power_hub_reactive_energy_import-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'reactive_energy', + : 'Power Hub Reactive energy import', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.power_hub_reactive_energy_import', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45.6699981689453', + }) +# --- +# name: test_all_entities[sensor.power_hub_reactive_power_export-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_hub_reactive_power_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reactive power export', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power export', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive_export', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_delivered_by_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.power_hub_reactive_power_export-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'reactive_power', + : 'Power Hub Reactive power export', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.power_hub_reactive_power_export', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[sensor.power_hub_reactive_power_export_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_hub_reactive_power_export_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reactive power export L1', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power export L1', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive_phase_export', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_l1_delivered_by_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.power_hub_reactive_power_export_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'reactive_power', + : 'Power Hub Reactive power export L1', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.power_hub_reactive_power_export_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[sensor.power_hub_reactive_power_export_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_hub_reactive_power_export_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reactive power export L2', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power export L2', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive_phase_export', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_l2_delivered_by_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.power_hub_reactive_power_export_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'reactive_power', + : 'Power Hub Reactive power export L2', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.power_hub_reactive_power_export_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[sensor.power_hub_reactive_power_export_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_hub_reactive_power_export_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reactive power export L3', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power export L3', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive_phase_export', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_l3_delivered_by_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.power_hub_reactive_power_export_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'reactive_power', + : 'Power Hub Reactive power export L3', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.power_hub_reactive_power_export_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[sensor.power_hub_reactive_power_import-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_hub_reactive_power_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reactive power import', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power import', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive_import', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_delivered_to_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.power_hub_reactive_power_import-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'reactive_power', + : 'Power Hub Reactive power import', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.power_hub_reactive_power_import', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.5', + }) +# --- +# name: test_all_entities[sensor.power_hub_reactive_power_import_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_hub_reactive_power_import_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reactive power import L1', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power import L1', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive_phase_import', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_l1_delivered_to_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.power_hub_reactive_power_import_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'reactive_power', + : 'Power Hub Reactive power import L1', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.power_hub_reactive_power_import_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.200000002980232', + }) +# --- +# name: test_all_entities[sensor.power_hub_reactive_power_import_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_hub_reactive_power_import_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reactive power import L2', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power import L2', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive_phase_import', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_l2_delivered_to_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.power_hub_reactive_power_import_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'reactive_power', + : 'Power Hub Reactive power import L2', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.power_hub_reactive_power_import_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.150000005960464', + }) +# --- +# name: test_all_entities[sensor.power_hub_reactive_power_import_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_hub_reactive_power_import_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reactive power import L3', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power import L3', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_reactive_phase_import', + 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_l3_delivered_to_client', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.power_hub_reactive_power_import_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'reactive_power', + : 'Power Hub Reactive power import L3', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.power_hub_reactive_power_import_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.150000005960464', + }) +# --- +# name: test_all_entities[sensor.power_hub_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.power_hub_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Uptime', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.power_hub_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'uptime', + : 'Power Hub Uptime', + }), + 'context': , + 'entity_id': 'sensor.power_hub_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-12-31T12:00:00+00:00', + }) +# --- +# name: test_all_entities[sensor.power_hub_voltage_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_hub_voltage_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voltage L1', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage L1', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_voltage', + 'unique_id': 'aa:bb:cc:dd:ee:ff_phase_voltage_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.power_hub_voltage_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Power Hub Voltage L1', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.power_hub_voltage_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.0', + }) +# --- +# name: test_all_entities[sensor.power_hub_voltage_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_hub_voltage_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voltage L2', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage L2', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_voltage', + 'unique_id': 'aa:bb:cc:dd:ee:ff_phase_voltage_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.power_hub_voltage_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Power Hub Voltage L2', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.power_hub_voltage_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '229.5', + }) +# --- +# name: test_all_entities[sensor.power_hub_voltage_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_hub_voltage_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voltage L3', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage L3', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_voltage', + 'unique_id': 'aa:bb:cc:dd:ee:ff_phase_voltage_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.power_hub_voltage_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Power Hub Voltage L3', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.power_hub_voltage_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '231.199996948242', + }) +# --- +# name: test_all_entities[sensor.power_hub_wi_fi_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.power_hub_wi_fi_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wi-Fi signal strength', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wi-Fi signal strength', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_rssi', + 'unique_id': 'aa:bb:cc:dd:ee:ff_wifi_rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_all_entities[sensor.power_hub_wi_fi_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'signal_strength', + : 'Power Hub Wi-Fi signal strength', + : , + : 'dBm', + }), + 'context': , + 'entity_id': 'sensor.power_hub_wi_fi_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-65', + }) +# --- diff --git a/tests/components/bitvis/test_config_flow.py b/tests/components/bitvis/test_config_flow.py new file mode 100644 index 00000000000000..7d33133685c6c9 --- /dev/null +++ b/tests/components/bitvis/test_config_flow.py @@ -0,0 +1,536 @@ +"""Tests for the Bitvis Power Hub config flow.""" + +import asyncio +from ipaddress import ip_address +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.bitvis.config_flow import _async_test_port +from homeassistant.components.bitvis.const import ( + DEFAULT_NAME, + DEFAULT_PORT, + DOMAIN, + MODEL_NAME, +) +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from tests.common import MockConfigEntry + +ZEROCONF_DISCOVERY = ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], + hostname="powerhub.local.", + name="Bitvis Power Hub._powerhub._udp.local.", + port=DEFAULT_PORT, + properties={}, + type="_powerhub._udp.local.", +) + + +async def test_user_form(hass: HomeAssistant) -> None: + """Test we get the user form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + +async def test_user_form_create_entry(hass: HomeAssistant) -> None: + """Test creating an entry via user flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with ( + patch( + "homeassistant.components.bitvis.config_flow._async_test_port", + new_callable=AsyncMock, + ), + patch( + "homeassistant.components.bitvis.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "192.168.1.100", + }, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == MODEL_NAME + assert result["data"] == { + CONF_HOST: "192.168.1.100", + CONF_PORT: DEFAULT_PORT, + } + assert hass.config_entries.async_entries(DOMAIN)[0].unique_id == DOMAIN + + +@pytest.mark.parametrize("recover", [False, True]) +async def test_user_form_cannot_connect(hass: HomeAssistant, recover: bool) -> None: + """Test user form error on port bind failure, optionally with recovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch( + "homeassistant.components.bitvis.config_flow._async_test_port", + side_effect=OSError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "192.168.1.100", + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + if recover: + with ( + patch( + "homeassistant.components.bitvis.config_flow._async_test_port", + new_callable=AsyncMock, + ), + patch( + "homeassistant.components.bitvis.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "192.168.1.100", + }, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == MODEL_NAME + assert result["data"] == { + CONF_HOST: "192.168.1.100", + CONF_PORT: DEFAULT_PORT, + } + + +async def test_user_form_duplicate(hass: HomeAssistant) -> None: + """Test duplicate detection.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: DEFAULT_PORT, + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch( + "homeassistant.components.bitvis.config_flow._async_test_port", + new_callable=AsyncMock, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "192.168.1.100", + }, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_confirm_creates_entry(hass: HomeAssistant) -> None: + """Test that zeroconf discovery shows confirmation form and creates entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + + with ( + patch( + "homeassistant.components.bitvis.config_flow._async_test_port", + new_callable=AsyncMock, + ), + patch( + "homeassistant.components.bitvis.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "192.168.1.200", + CONF_PORT: DEFAULT_PORT, + } + assert hass.config_entries.async_entries(DOMAIN)[0].unique_id == DOMAIN + + +@pytest.mark.parametrize("recover", [False, True]) +async def test_zeroconf_confirm_cannot_connect( + hass: HomeAssistant, recover: bool +) -> None: + """Test zeroconf confirm abort on port bind failure, optionally with recovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + + with patch( + "homeassistant.components.bitvis.config_flow._async_test_port", + side_effect=OSError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + if recover: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + + with ( + patch( + "homeassistant.components.bitvis.config_flow._async_test_port", + new_callable=AsyncMock, + ), + patch( + "homeassistant.components.bitvis.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "192.168.1.200", + CONF_PORT: DEFAULT_PORT, + } + + +async def test_zeroconf_duplicate(hass: HomeAssistant) -> None: + """Test that a duplicate zeroconf discovery is aborted.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.200", CONF_PORT: DEFAULT_PORT}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_none_port_uses_default(hass: HomeAssistant) -> None: + """Test that a zeroconf discovery with port=None falls back to DEFAULT_PORT.""" + discovery = ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], + hostname="powerhub.local.", + name="Bitvis Power Hub._powerhub._udp.local.", + port=None, + properties={}, + type="_powerhub._udp.local.", + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery, + ) + + with ( + patch( + "homeassistant.components.bitvis.config_flow._async_test_port", + new_callable=AsyncMock, + ), + patch( + "homeassistant.components.bitvis.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"][CONF_PORT] == DEFAULT_PORT + + +async def test_user_form_create_entry_ipv6_host(hass: HomeAssistant) -> None: + """Test creating an entry with an IPv6 host via user flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with ( + patch( + "homeassistant.components.bitvis.config_flow._async_test_port", + new_callable=AsyncMock, + ), + patch( + "homeassistant.components.bitvis.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "2001:db8::10", + }, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == MODEL_NAME + assert result["data"] == { + CONF_HOST: "2001:db8::10", + CONF_PORT: DEFAULT_PORT, + } + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert hass.config_entries.async_entries(DOMAIN)[0].unique_id == DOMAIN + + +async def test_user_form_duplicate_host(hass: HomeAssistant) -> None: + """Test duplicate detection uses the configured host.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "2001:db8::10", + CONF_PORT: DEFAULT_PORT, + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch( + "homeassistant.components.bitvis.config_flow._async_test_port", + new_callable=AsyncMock, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "2001:db8::10", + }, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_user_form_keeps_hostname( + hass: HomeAssistant, +) -> None: + """Test that user flow keeps the configured hostname.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with ( + patch( + "homeassistant.components.bitvis.config_flow._async_test_port", + new_callable=AsyncMock, + ), + patch( + "homeassistant.components.bitvis.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "my-powerhub.local", + }, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == MODEL_NAME + assert result["data"][CONF_HOST] == "my-powerhub.local" + + +async def test_user_form_normalize_bracketed_ipv6( + hass: HomeAssistant, +) -> None: + """Test that bracketed IPv6 host is normalized (brackets stripped).""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with ( + patch( + "homeassistant.components.bitvis.config_flow._async_test_port", + new_callable=AsyncMock, + ), + patch( + "homeassistant.components.bitvis.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "[2001:db8::10]", + }, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == MODEL_NAME + assert result["data"][CONF_HOST] == "2001:db8::10" + + +async def test_zeroconf_confirm_uses_friendly_name(hass: HomeAssistant) -> None: + """Test that zeroconf confirm creates entry with friendly name from discovery.""" + discovery = ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], + hostname="powerhub.local.", + name="My Custom Hub._powerhub._udp.local.", + port=DEFAULT_PORT, + properties={}, + type="_powerhub._udp.local.", + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery, + ) + + with ( + patch( + "homeassistant.components.bitvis.config_flow._async_test_port", + new_callable=AsyncMock, + ), + patch( + "homeassistant.components.bitvis.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "My Custom Hub" + + +async def test_zeroconf_empty_name_uses_default(hass: HomeAssistant) -> None: + """Test that zeroconf with empty name falls back to DEFAULT_NAME.""" + discovery = ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.201"), + ip_addresses=[ip_address("192.168.1.201")], + hostname="powerhub.local.", + name="", + port=DEFAULT_PORT, + properties={}, + type="_powerhub._udp.local.", + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery, + ) + + with ( + patch( + "homeassistant.components.bitvis.config_flow._async_test_port", + new_callable=AsyncMock, + ), + patch( + "homeassistant.components.bitvis.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + + +async def test_async_test_port_skips_when_listener_exists( + hass: HomeAssistant, +) -> None: + """Test _async_test_port returns immediately when a listener already exists.""" + with patch( + "homeassistant.components.bitvis.config_flow.async_get_listener_registry", + ) as mock_registry: + mock_registry.return_value.has_listener.return_value = True + # Should return without attempting to bind + await _async_test_port(hass, 5000) + + mock_registry.return_value.has_listener.assert_called_once_with(5000) + + +async def test_async_test_port_binds_and_closes(hass: HomeAssistant) -> None: + """Test _async_test_port binds transports and closes them.""" + mock_transport = MagicMock(spec=asyncio.DatagramTransport) + + with ( + patch( + "homeassistant.components.bitvis.config_flow.async_get_listener_registry", + ) as mock_registry, + patch.object( + hass.loop, + "create_datagram_endpoint", + new_callable=AsyncMock, + return_value=(mock_transport, MagicMock()), + ), + ): + mock_registry.return_value.has_listener.return_value = False + await _async_test_port(hass, 5000) + + mock_transport.close.assert_called() + + +async def test_async_test_port_raises_when_all_binds_fail( + hass: HomeAssistant, +) -> None: + """Test _async_test_port raises OSError when no binds succeed.""" + with patch( + "homeassistant.components.bitvis.config_flow.async_get_listener_registry", + ) as mock_registry: + mock_registry.return_value.has_listener.return_value = False + with ( + patch.object( + hass.loop, + "create_datagram_endpoint", + new_callable=AsyncMock, + side_effect=OSError("bind failed"), + ), + pytest.raises(OSError, match="UDP port is unavailable"), + ): + await _async_test_port(hass, 5000) diff --git a/tests/components/bitvis/test_coordinator.py b/tests/components/bitvis/test_coordinator.py new file mode 100644 index 00000000000000..9790a03bcfd15a --- /dev/null +++ b/tests/components/bitvis/test_coordinator.py @@ -0,0 +1,314 @@ +"""Tests for the Bitvis Power Hub coordinator.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from bitvis_protobuf import powerhub_pb2 +from bitvis_protobuf.parse import PayloadDiagnostic, PayloadSample +import pytest + +from homeassistant.components.bitvis.const import DOMAIN, MODEL_NAME +from homeassistant.components.bitvis.coordinator import ( + BitvisDataUpdateCoordinator, + BitvisListenerRegistry, + async_get_listener_registry, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import UpdateFailed + +from .conftest import TEST_DEVICE_MAC + +from tests.common import MockConfigEntry + + +@pytest.fixture +def config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={"host": "192.168.1.100", "port": 5000}, + unique_id=TEST_DEVICE_MAC, + title=MODEL_NAME, + ) + + +@pytest.fixture +def coordinator( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> BitvisDataUpdateCoordinator: + """Return a coordinator instance (not started).""" + config_entry.add_to_hass(hass) + return BitvisDataUpdateCoordinator(hass, config_entry, "192.168.1.100", 5000) + + +# --------------------------------------------------------------------------- +# BitvisDataUpdateCoordinator._handle_payload tests +# --------------------------------------------------------------------------- + + +def test_handle_payload_sample( + coordinator: BitvisDataUpdateCoordinator, +) -> None: + """Test that a PayloadSample is dispatched to _handle_sample.""" + payload = powerhub_pb2.Payload() + payload.sample.power_active_delivered_to_client_kw = 1.5 + parsed = PayloadSample(sample=payload.sample) + + with patch.object(coordinator, "_handle_sample") as mock_handle: + coordinator._handle_payload(parsed, ("192.168.1.100", 1234)) + + mock_handle.assert_called_once_with(parsed) + + +def test_handle_payload_diagnostic( + coordinator: BitvisDataUpdateCoordinator, +) -> None: + """Test that a PayloadDiagnostic is dispatched to _handle_diagnostic.""" + payload = powerhub_pb2.Payload() + payload.diagnostic.uptime_s = 42 + parsed = PayloadDiagnostic(diagnostic=payload.diagnostic) + + with patch.object(coordinator, "_handle_diagnostic") as mock_handle: + coordinator._handle_payload(parsed, ("192.168.1.100", 1234)) + + mock_handle.assert_called_once_with(parsed) + + +# --------------------------------------------------------------------------- +# BitvisDataUpdateCoordinator._async_setup / async_stop tests +# --------------------------------------------------------------------------- + + +async def test_coordinator_async_setup_registers_callback( + hass: HomeAssistant, coordinator: BitvisDataUpdateCoordinator +) -> None: + """Test that _async_setup registers callback with listener.""" + mock_listener = MagicMock() + mock_listener.start = AsyncMock() + mock_listener.stop = AsyncMock() + mock_listener.is_empty = True + + with patch( + "homeassistant.components.bitvis.coordinator.SharedListener", + return_value=mock_listener, + ): + await coordinator._async_setup() + + mock_listener.register.assert_called_once() + registered_callback = mock_listener.register.call_args[0][1] + assert callable(registered_callback) + + await coordinator.async_stop() + + +async def test_coordinator_async_setup_reuses_existing_listener( + hass: HomeAssistant, coordinator: BitvisDataUpdateCoordinator +) -> None: + """Test that a second coordinator on the same port reuses the shared listener.""" + config_entry2 = MockConfigEntry( + domain=DOMAIN, + data={"host": "192.168.1.101", "port": 5000}, + unique_id="11:22:33:44:55:66", + ) + config_entry2.add_to_hass(hass) + coordinator2 = BitvisDataUpdateCoordinator( + hass, config_entry2, "192.168.1.101", 5000 + ) + + mock_listener = MagicMock() + mock_listener.start = AsyncMock() + mock_listener.stop = AsyncMock() + mock_listener.is_empty = False + + with patch( + "homeassistant.components.bitvis.coordinator.SharedListener", + return_value=mock_listener, + ): + await coordinator._async_setup() + await coordinator2._async_setup() + + # SharedListener() should only be instantiated once + mock_listener.start.assert_called_once() + + registry = async_get_listener_registry(hass) + assert isinstance(registry, BitvisListenerRegistry) + assert registry.get(5000) is not None + + # Stop first — listener stays (still registered by coordinator2) + mock_listener.is_empty = False + await coordinator.async_stop() + mock_listener.stop.assert_not_called() + + # Stop second — listener torn down + mock_listener.is_empty = True + await coordinator2.async_stop() + mock_listener.stop.assert_called_once() + + assert registry.get(5000) is None + + +async def test_coordinator_async_setup_oserror_raises_update_failed( + hass: HomeAssistant, coordinator: BitvisDataUpdateCoordinator +) -> None: + """Test that OSError from listener.start raises UpdateFailed.""" + mock_listener = MagicMock() + mock_listener.start = AsyncMock(side_effect=OSError("port in use")) + + with ( + patch( + "homeassistant.components.bitvis.coordinator.SharedListener", + return_value=mock_listener, + ), + pytest.raises(UpdateFailed), + ): + await coordinator._async_setup() + + +async def test_coordinator_async_setup_runtime_error_raises_entry_error( + hass: HomeAssistant, coordinator: BitvisDataUpdateCoordinator +) -> None: + """Test that RuntimeError from listener.register raises ConfigEntryError.""" + mock_listener = MagicMock() + mock_listener.start = AsyncMock() + mock_listener.stop = AsyncMock() + mock_listener.is_empty = True + mock_listener.register.side_effect = RuntimeError("duplicate IP") + + with ( + patch( + "homeassistant.components.bitvis.coordinator.SharedListener", + return_value=mock_listener, + ), + pytest.raises(ConfigEntryError), + ): + await coordinator._async_setup() + + +# --------------------------------------------------------------------------- +# _async_update_data +# --------------------------------------------------------------------------- + + +async def test_coordinator_update_data_returns_data( + coordinator: BitvisDataUpdateCoordinator, +) -> None: + """Test that _async_update_data returns current data (push-based coordinator).""" + result = await coordinator._async_update_data() + assert result is coordinator.data + + +# --------------------------------------------------------------------------- +# _handle_sample tests +# --------------------------------------------------------------------------- + + +async def test_coordinator_handle_sample( + hass: HomeAssistant, coordinator: BitvisDataUpdateCoordinator +) -> None: + """Test that _handle_sample updates coordinator data and notifies listeners.""" + payload = powerhub_pb2.Payload() + payload.sample.power_active_delivered_to_client_kw = 2.5 + parsed = PayloadSample(sample=payload.sample) + + ha_listener = MagicMock() + coordinator.async_add_listener(ha_listener) + + coordinator._handle_sample(parsed) + await hass.async_block_till_done() + + assert coordinator.data.sample is parsed + assert coordinator.last_update_success is True + ha_listener.assert_called() + + +# --------------------------------------------------------------------------- +# _handle_diagnostic tests +# --------------------------------------------------------------------------- + + +async def test_coordinator_handle_diagnostic( + hass: HomeAssistant, coordinator: BitvisDataUpdateCoordinator +) -> None: + """Test that _handle_diagnostic updates coordinator data and notifies listeners.""" + payload = powerhub_pb2.Payload() + payload.diagnostic.uptime_s = 999 + parsed = PayloadDiagnostic(diagnostic=payload.diagnostic) + + ha_listener = MagicMock() + coordinator.async_add_listener(ha_listener) + + coordinator._handle_diagnostic(parsed) + await hass.async_block_till_done() + + assert coordinator.data.diagnostic is parsed + assert coordinator.data.boot_time is not None + ha_listener.assert_called() + + +async def test_coordinator_handle_diagnostic_with_device_info( + hass: HomeAssistant, coordinator: BitvisDataUpdateCoordinator +) -> None: + """Test that device_info fields are extracted and stored from diagnostic payload.""" + payload = powerhub_pb2.Payload() + payload.diagnostic.uptime_s = 10 + payload.diagnostic.device_info.model_name = "PowerHub Gen2" + payload.diagnostic.device_info.sw_version = "1.2.3" + payload.diagnostic.device_info.mac_address = b"\xaa\xbb\xcc\xdd\xee\xff" + + coordinator._handle_diagnostic(PayloadDiagnostic(diagnostic=payload.diagnostic)) + await hass.async_block_till_done() + + assert coordinator.data.model_name == "PowerHub Gen2" + assert coordinator.data.sw_version == "1.2.3" + assert coordinator.data.mac_address == "aa:bb:cc:dd:ee:ff" + + +async def test_coordinator_handle_diagnostic_clears_device_info( + hass: HomeAssistant, coordinator: BitvisDataUpdateCoordinator +) -> None: + """Test that device_info fields are cleared when diagnostic has no device_info.""" + payload = powerhub_pb2.Payload() + payload.diagnostic.uptime_s = 10 + payload.diagnostic.device_info.model_name = "PowerHub" + payload.diagnostic.device_info.sw_version = "1.0" + payload.diagnostic.device_info.mac_address = b"\xaa\xbb\xcc\xdd\xee\xff" + coordinator._handle_diagnostic(PayloadDiagnostic(diagnostic=payload.diagnostic)) + assert coordinator.data.model_name == "PowerHub" + + payload2 = powerhub_pb2.Payload() + payload2.diagnostic.uptime_s = 20 + coordinator._handle_diagnostic(PayloadDiagnostic(diagnostic=payload2.diagnostic)) + + assert coordinator.data.mac_address is None + assert coordinator.data.model_name is None + assert coordinator.data.sw_version is None + + +# --------------------------------------------------------------------------- +# async_stop edge cases +# --------------------------------------------------------------------------- + + +async def test_coordinator_async_stop_without_listener( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test that async_stop works when no listener is registered.""" + config_entry.add_to_hass(hass) + coordinator = BitvisDataUpdateCoordinator(hass, config_entry, "192.168.1.100", 5000) + + await coordinator.async_stop() + + assert coordinator._registered_ips == set() + + +async def test_coordinator_async_stop_without_domain_data( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test that async_stop works when hass.data has no domain data.""" + config_entry.add_to_hass(hass) + coordinator = BitvisDataUpdateCoordinator(hass, config_entry, "192.168.1.100", 5000) + hass.data.pop(DOMAIN, None) + + await coordinator.async_stop() + + assert coordinator._registered_ips == set() diff --git a/tests/components/bitvis/test_init.py b/tests/components/bitvis/test_init.py new file mode 100644 index 00000000000000..61eab1813e3c89 --- /dev/null +++ b/tests/components/bitvis/test_init.py @@ -0,0 +1,63 @@ +"""Tests for the Bitvis Power Hub integration.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_entry( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test successful integration setup.""" + assert init_integration.state is ConfigEntryState.LOADED + assert init_integration.runtime_data is not None + + +async def test_setup_entry_oserror( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that OSError from SharedListener.start results in SETUP_RETRY.""" + mock_config_entry.add_to_hass(hass) + mock_listener = MagicMock() + mock_listener.start = AsyncMock(side_effect=OSError("port in use")) + mock_listener.stop = AsyncMock() + mock_listener.is_empty = True + with patch( + "homeassistant.components.bitvis.coordinator.SharedListener", + return_value=mock_listener, + ): + 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_runtime_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that RuntimeError from SharedListener.register results in SETUP_ERROR.""" + mock_config_entry.add_to_hass(hass) + mock_listener = MagicMock() + mock_listener.start = AsyncMock() + mock_listener.stop = AsyncMock() + mock_listener.is_empty = True + mock_listener.register.side_effect = RuntimeError("duplicate IP registration") + with patch( + "homeassistant.components.bitvis.coordinator.SharedListener", + return_value=mock_listener, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_unload_entry( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test that unloading stops the coordinator and unloads platforms.""" + assert await hass.config_entries.async_unload(init_integration.entry_id) + assert init_integration.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/bitvis/test_sensor.py b/tests/components/bitvis/test_sensor.py new file mode 100644 index 00000000000000..2f2eb949545144 --- /dev/null +++ b/tests/components/bitvis/test_sensor.py @@ -0,0 +1,142 @@ +"""Tests for the Bitvis Power Hub sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from bitvis_protobuf import powerhub_pb2 +from bitvis_protobuf.parse import PayloadDiagnostic, PayloadSample +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def enable_all_entities(entity_registry_enabled_by_default: None) -> None: + """Make sure all entities are enabled.""" + + +@pytest.fixture +def sample_payload() -> PayloadSample: + """Return a sample payload with test data.""" + payload = powerhub_pb2.Payload() + payload.sample.phase_voltage_l1_v = 230.0 + payload.sample.phase_voltage_l2_v = 229.5 + payload.sample.phase_voltage_l3_v = 231.2 + payload.sample.phase_current_l1_a = 10.5 + payload.sample.phase_current_l2_a = 8.3 + payload.sample.phase_current_l3_a = 12.1 + payload.sample.power_active_delivered_to_client_kw = 2.415 + payload.sample.power_active_delivered_by_client_kw = 0.0 + payload.sample.power_reactive_delivered_to_client_kvar = 0.5 + payload.sample.power_reactive_delivered_by_client_kvar = 0.0 + payload.sample.power_active_l1_delivered_to_client_kw = 0.8 + payload.sample.power_active_l2_delivered_to_client_kw = 0.7 + payload.sample.power_active_l3_delivered_to_client_kw = 0.915 + payload.sample.power_active_l1_delivered_by_client_kw = 0.0 + payload.sample.power_active_l2_delivered_by_client_kw = 0.0 + payload.sample.power_active_l3_delivered_by_client_kw = 0.0 + payload.sample.power_reactive_l1_delivered_to_client_kvar = 0.2 + payload.sample.power_reactive_l2_delivered_to_client_kvar = 0.15 + payload.sample.power_reactive_l3_delivered_to_client_kvar = 0.15 + payload.sample.power_reactive_l1_delivered_by_client_kvar = 0.0 + payload.sample.power_reactive_l2_delivered_by_client_kvar = 0.0 + payload.sample.power_reactive_l3_delivered_by_client_kvar = 0.0 + payload.sample.energy_active_delivered_to_client_kwh = 1234.56 + payload.sample.energy_active_delivered_by_client_kwh = 789.12 + payload.sample.energy_reactive_delivered_to_client_kvarh = 45.67 + payload.sample.energy_reactive_delivered_by_client_kvarh = 23.45 + return PayloadSample(sample=payload.sample) + + +@pytest.fixture +def diagnostic_payload() -> PayloadDiagnostic: + """Return a diagnostic payload with test data.""" + payload = powerhub_pb2.Payload() + payload.diagnostic.uptime_s = 86400 + payload.diagnostic.wifi_rssi_dbm = -65 + payload.diagnostic.device_info.model_name = "PowerHub Gen2" + payload.diagnostic.device_info.sw_version = "2.0.0" + payload.diagnostic.device_info.mac_address = b"\xaa\xbb\xcc\xdd\xee\xff" + payload.diagnostic.han_msg_successfully_parsed = 1000 + payload.diagnostic.han_msg_buffer_overflow = 5 + return PayloadDiagnostic(diagnostic=payload.diagnostic) + + +@pytest.mark.freeze_time("2026-01-01 12:00:00") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + sample_payload: PayloadSample, + diagnostic_payload: PayloadDiagnostic, +) -> None: + """Test all entities with snapshot.""" + mock_config_entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.bitvis.coordinator.BitvisDataUpdateCoordinator._async_setup", + new_callable=AsyncMock, + ), + patch("homeassistant.components.bitvis._PLATFORMS", [Platform.SENSOR]), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + coordinator = mock_config_entry.runtime_data + coordinator._handle_sample(sample_payload) + coordinator._handle_diagnostic(diagnostic_payload) + await hass.async_block_till_done() + + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_sensors_unavailable_without_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that sensors are unavailable when coordinator has no data.""" + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + sensor_entity = next(e for e in entities if e.domain == "sensor") + state = hass.states.get(sensor_entity.entity_id) + assert state is not None + assert state.state == "unavailable" + + +@pytest.mark.usefixtures("init_integration") +async def test_sensors_become_available_with_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that sensors become available when data arrives.""" + coordinator = mock_config_entry.runtime_data + + payload = powerhub_pb2.Payload() + payload.sample.power_active_delivered_to_client_kw = 2.0 + coordinator._handle_sample(PayloadSample(sample=payload.sample)) + await hass.async_block_till_done() + + base_unique_id = mock_config_entry.unique_id + expected_unique_id = f"{base_unique_id}_power_active_delivered_to_client" + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + matching = next( + (e for e in entity_entries if e.unique_id == expected_unique_id), None + ) + assert matching is not None + state = hass.states.get(matching.entity_id) + assert state is not None + assert state.state != "unavailable" + assert float(state.state) == pytest.approx(2.0)