From a2954c270638e7769bdb39dcafbe04da2454a683 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mandus=20B=C3=B6rjesson?= Date: Wed, 11 Feb 2026 12:12:20 +0100 Subject: [PATCH 1/4] Add Bitvis Power Hub integration --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/bitvis/__init__.py | 33 + .../components/bitvis/config_flow.py | 149 ++ homeassistant/components/bitvis/const.py | 19 + .../components/bitvis/coordinator.py | 184 ++ homeassistant/components/bitvis/manifest.json | 13 + .../components/bitvis/quality_scale.yaml | 74 + homeassistant/components/bitvis/sensor.py | 559 ++++++ homeassistant/components/bitvis/strings.json | 123 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + homeassistant/generated/zeroconf.py | 5 + mypy.ini | 10 + requirements_all.txt | 3 + tests/components/bitvis/__init__.py | 1 + tests/components/bitvis/conftest.py | 50 + .../bitvis/snapshots/test_sensor.ambr | 1721 +++++++++++++++++ tests/components/bitvis/test_config_flow.py | 559 ++++++ tests/components/bitvis/test_coordinator.py | 314 +++ tests/components/bitvis/test_init.py | 63 + tests/components/bitvis/test_sensor.py | 142 ++ 22 files changed, 4032 insertions(+) create mode 100644 homeassistant/components/bitvis/__init__.py create mode 100644 homeassistant/components/bitvis/config_flow.py create mode 100644 homeassistant/components/bitvis/const.py create mode 100644 homeassistant/components/bitvis/coordinator.py create mode 100644 homeassistant/components/bitvis/manifest.json create mode 100644 homeassistant/components/bitvis/quality_scale.yaml create mode 100644 homeassistant/components/bitvis/sensor.py create mode 100644 homeassistant/components/bitvis/strings.json create mode 100644 tests/components/bitvis/__init__.py create mode 100644 tests/components/bitvis/conftest.py create mode 100644 tests/components/bitvis/snapshots/test_sensor.ambr create mode 100644 tests/components/bitvis/test_config_flow.py create mode 100644 tests/components/bitvis/test_coordinator.py create mode 100644 tests/components/bitvis/test_init.py create mode 100644 tests/components/bitvis/test_sensor.py diff --git a/.strict-typing b/.strict-typing index 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..f0d4e01bd25675 --- /dev/null +++ b/homeassistant/components/bitvis/config_flow.py @@ -0,0 +1,149 @@ +"""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, + get_mac_address_for_host, + 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__) + + +async def _async_test_port(hass: HomeAssistant, port: int) -> None: + """Verify the UDP port can be bound.""" + + if async_get_listener_registry(hass).has_listener(port): + return + + await async_verify_udp_port_bindable(port) + + +async def _async_get_device_unique_id(hass: HomeAssistant, host: str) -> str: + """Resolve *host* and look up a MAC address for the config entry unique ID.""" + return await hass.async_add_executor_job(get_mac_address_for_host, host) + + +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]) + port = user_input[CONF_PORT] + + try: + await _async_test_port(self.hass, port) + except OSError: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id( + await _async_get_device_unique_id(self.hass, host) + ) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=MODEL_NAME, + data={ + CONF_HOST: host, + CONF_PORT: port, + }, + ) + + data_schema = vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + } + ) + + 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( + await _async_get_device_unique_id(self.hass, host) + ) + self._abort_if_unique_id_configured() + + 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 + port = self._discovery_info.port or DEFAULT_PORT + + try: + await _async_test_port(self.hass, 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: 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..ab6efd2adcd34f --- /dev/null +++ b/homeassistant/components/bitvis/sensor.py @@ -0,0 +1,559 @@ +"""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_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_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_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_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_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_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_delivered_to_client", + 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_delivered_by_client", + 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_delivered_to_client", + 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_delivered_by_client", + 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_l1_delivered_to_client", + 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_l2_delivered_to_client", + 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_l3_delivered_to_client", + 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_l1_delivered_by_client", + 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_l2_delivered_by_client", + 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_l3_delivered_by_client", + 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_l1_delivered_to_client", + 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_l2_delivered_to_client", + 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_l3_delivered_to_client", + 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_l1_delivered_by_client", + 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_l2_delivered_by_client", + 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_l3_delivered_by_client", + 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_delivered_to_client", + 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_delivered_by_client", + 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_delivered_to_client", + 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_delivered_by_client", + 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", + translation_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.""" + if self.coordinator.data.sample is None: + return None + return self.entity_description.value_fn(self.coordinator.data.sample.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.""" + if self.coordinator.data.diagnostic is None: + return None + return self.entity_description.value_fn( + self.coordinator.data.diagnostic.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..19ecd6717bf772 --- /dev/null +++ b/homeassistant/components/bitvis/strings.json @@ -0,0 +1,123 @@ +{ + "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%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "Hostname or IP address of your Bitvis Power Hub device.", + "port": "UDP port the Bitvis Power Hub sends data to." + }, + "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_delivered_by_client": { + "name": "Active energy export" + }, + "energy_active_delivered_to_client": { + "name": "Active energy import" + }, + "energy_reactive_delivered_by_client": { + "name": "Reactive energy export" + }, + "energy_reactive_delivered_to_client": { + "name": "Reactive energy import" + }, + "han_msg_buffer_overflow": { + "name": "HAN buffer overflows" + }, + "han_msg_successfully_parsed": { + "name": "HAN messages successfully parsed" + }, + "phase_current_l1": { + "name": "Current L1" + }, + "phase_current_l2": { + "name": "Current L2" + }, + "phase_current_l3": { + "name": "Current L3" + }, + "phase_voltage_l1": { + "name": "Voltage L1" + }, + "phase_voltage_l2": { + "name": "Voltage L2" + }, + "phase_voltage_l3": { + "name": "Voltage L3" + }, + "power_active_delivered_by_client": { + "name": "Active power export" + }, + "power_active_delivered_to_client": { + "name": "Active power import" + }, + "power_active_l1_delivered_by_client": { + "name": "Active power export L1" + }, + "power_active_l1_delivered_to_client": { + "name": "Active power import L1" + }, + "power_active_l2_delivered_by_client": { + "name": "Active power export L2" + }, + "power_active_l2_delivered_to_client": { + "name": "Active power import L2" + }, + "power_active_l3_delivered_by_client": { + "name": "Active power export L3" + }, + "power_active_l3_delivered_to_client": { + "name": "Active power import L3" + }, + "power_reactive_delivered_by_client": { + "name": "Reactive power export" + }, + "power_reactive_delivered_to_client": { + "name": "Reactive power import" + }, + "power_reactive_l1_delivered_by_client": { + "name": "Reactive power export L1" + }, + "power_reactive_l1_delivered_to_client": { + "name": "Reactive power import L1" + }, + "power_reactive_l2_delivered_by_client": { + "name": "Reactive power export L2" + }, + "power_reactive_l2_delivered_to_client": { + "name": "Reactive power import L2" + }, + "power_reactive_l3_delivered_by_client": { + "name": "Reactive power export L3" + }, + "power_reactive_l3_delivered_to_client": { + "name": "Reactive power import L3" + }, + "uptime": { + "name": "Uptime" + }, + "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..072ff3e35f1843 --- /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_delivered_by_client', + '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_delivered_to_client', + '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_delivered_by_client', + '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_l1_delivered_by_client', + '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_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_export_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Active power export L2', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power export L2', + 'platform': 'bitvis', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_active_l2_delivered_by_client', + '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_export_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Power Hub Active power export L2', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.power_hub_active_power_export_l2', + '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_l3_delivered_by_client', + '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_delivered_to_client', + '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_l1_delivered_to_client', + '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_l2_delivered_to_client', + '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_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_l3_delivered_to_client', + '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_l1', + '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_l2', + '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_l3', + '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_delivered_by_client', + '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_delivered_to_client', + '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_delivered_by_client', + '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_l1_delivered_by_client', + '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_l2_delivered_by_client', + '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_l3_delivered_by_client', + '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_delivered_to_client', + '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_l1_delivered_to_client', + '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_l2_delivered_to_client', + '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_l3_delivered_to_client', + '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': 'uptime', + '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_l1', + '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_l2', + '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_l3', + '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..52a327a84b40a7 --- /dev/null +++ b/tests/components/bitvis/test_config_flow.py @@ -0,0 +1,559 @@ +"""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 .conftest import TEST_DEVICE_MAC + +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.", +) + + +@pytest.fixture(autouse=True) +def mock_get_mac_address_for_host() -> None: + """Mock MAC address lookup for config flow tests.""" + with patch( + "homeassistant.components.bitvis.config_flow.get_mac_address_for_host", + return_value=TEST_DEVICE_MAC, + ): + yield + + +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", + CONF_PORT: 5000, + }, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == MODEL_NAME + assert result["data"] == { + CONF_HOST: "192.168.1.100", + CONF_PORT: 5000, + } + assert hass.config_entries.async_entries(DOMAIN)[0].unique_id == TEST_DEVICE_MAC + + +@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", + CONF_PORT: 5000, + }, + ) + + 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", + CONF_PORT: 5000, + }, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == MODEL_NAME + assert result["data"] == { + CONF_HOST: "192.168.1.100", + CONF_PORT: 5000, + } + + +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: 5000, + }, + unique_id=TEST_DEVICE_MAC, + ) + 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", + CONF_PORT: 5000, + }, + ) + + 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, + } + + +@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}, + unique_id=TEST_DEVICE_MAC, + ) + 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", + CONF_PORT: 5000, + }, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == MODEL_NAME + assert result["data"] == { + CONF_HOST: "2001:db8::10", + CONF_PORT: 5000, + } + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert hass.config_entries.async_entries(DOMAIN)[0].unique_id == TEST_DEVICE_MAC + + +async def test_user_form_duplicate_mac(hass: HomeAssistant) -> None: + """Test duplicate detection uses the device MAC address.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "2001:db8::10", + CONF_PORT: 5000, + }, + unique_id=TEST_DEVICE_MAC, + ) + 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", + CONF_PORT: 5001, + }, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_user_form_resolve_host_failure_uses_host_for_mac_lookup( + hass: HomeAssistant, +) -> None: + """Test that user flow falls back to raw host when DNS resolution fails.""" + 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", + CONF_PORT: 5000, + }, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == MODEL_NAME + assert result["data"][CONF_HOST] == "my-powerhub.local" + assert hass.config_entries.async_entries(DOMAIN)[0].unique_id == TEST_DEVICE_MAC + + +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]", + CONF_PORT: 5000, + }, + ) + + 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) From e48da623bbb1659b59e432f2c5cd3c801fea9309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mandus=20B=C3=B6rjesson?= Date: Wed, 12 Aug 2026 13:03:12 +0200 Subject: [PATCH 2/4] fixup! Add Bitvis Power Hub integration --- .../components/bitvis/config_flow.py | 38 +++----- homeassistant/components/bitvis/sensor.py | 95 ++++++++++++------- homeassistant/components/bitvis/strings.json | 95 ++++--------------- .../bitvis/snapshots/test_sensor.ambr | 54 +++++------ tests/components/bitvis/test_config_flow.py | 47 +++------ 5 files changed, 130 insertions(+), 199 deletions(-) diff --git a/homeassistant/components/bitvis/config_flow.py b/homeassistant/components/bitvis/config_flow.py index f0d4e01bd25675..4cb575cb65af24 100644 --- a/homeassistant/components/bitvis/config_flow.py +++ b/homeassistant/components/bitvis/config_flow.py @@ -3,11 +3,7 @@ import logging from typing import Any, override -from bitvis_protobuf.utils import ( - async_verify_udp_port_bindable, - get_mac_address_for_host, - normalize_host, -) +from bitvis_protobuf.utils import async_verify_udp_port_bindable, normalize_host import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -21,6 +17,8 @@ _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.""" @@ -31,11 +29,6 @@ async def _async_test_port(hass: HomeAssistant, port: int) -> None: await async_verify_udp_port_bindable(port) -async def _async_get_device_unique_id(hass: HomeAssistant, host: str) -> str: - """Resolve *host* and look up a MAC address for the config entry unique ID.""" - return await hass.async_add_executor_job(get_mac_address_for_host, host) - - class BitvisConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Bitvis Power Hub.""" @@ -59,30 +52,27 @@ async def async_step_user( if user_input is not None: host = normalize_host(user_input[CONF_HOST]) - port = user_input[CONF_PORT] + + 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, port) + await _async_test_port(self.hass, DEFAULT_PORT) except OSError: errors["base"] = "cannot_connect" else: - await self.async_set_unique_id( - await _async_get_device_unique_id(self.hass, host) - ) - self._abort_if_unique_id_configured() - return self.async_create_entry( title=MODEL_NAME, data={ CONF_HOST: host, - CONF_PORT: port, + CONF_PORT: DEFAULT_PORT, }, ) data_schema = vol.Schema( { vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, } ) @@ -101,10 +91,9 @@ async def async_step_zeroconf( host = discovery_info.host - await self.async_set_unique_id( - await _async_get_device_unique_id(self.hass, 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 @@ -123,10 +112,9 @@ async def async_step_zeroconf_confirm( if user_input is not None: assert self._discovery_info is not None host = self._discovery_info.host - port = self._discovery_info.port or DEFAULT_PORT try: - await _async_test_port(self.hass, port) + await _async_test_port(self.hass, DEFAULT_PORT) except OSError: return self.async_abort(reason="cannot_connect") @@ -134,7 +122,7 @@ async def async_step_zeroconf_confirm( title=self._get_friendly_name(self._discovery_info.name), data={ CONF_HOST: host, - CONF_PORT: port, + CONF_PORT: DEFAULT_PORT, }, ) diff --git a/homeassistant/components/bitvis/sensor.py b/homeassistant/components/bitvis/sensor.py index ab6efd2adcd34f..7bfad08aa21e66 100644 --- a/homeassistant/components/bitvis/sensor.py +++ b/homeassistant/components/bitvis/sensor.py @@ -76,7 +76,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): # Phase voltages BitvisSensorEntityDescription( key="phase_voltage_l1", - translation_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, @@ -87,7 +88,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): ), BitvisSensorEntityDescription( key="phase_voltage_l2", - translation_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, @@ -98,7 +100,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): ), BitvisSensorEntityDescription( key="phase_voltage_l3", - translation_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, @@ -110,7 +113,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): # Phase currents BitvisSensorEntityDescription( key="phase_current_l1", - translation_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, @@ -121,7 +125,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): ), BitvisSensorEntityDescription( key="phase_current_l2", - translation_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, @@ -132,7 +137,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): ), BitvisSensorEntityDescription( key="phase_current_l3", - translation_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, @@ -144,7 +150,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): # Total active power BitvisSensorEntityDescription( key="power_active_delivered_to_client", - translation_key="power_active_delivered_to_client", + translation_key="power_active", + translation_placeholders={"direction": "import"}, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, state_class=SensorStateClass.MEASUREMENT, @@ -157,7 +164,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): ), BitvisSensorEntityDescription( key="power_active_delivered_by_client", - translation_key="power_active_delivered_by_client", + translation_key="power_active", + translation_placeholders={"direction": "export"}, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, state_class=SensorStateClass.MEASUREMENT, @@ -171,7 +179,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): # Total reactive power BitvisSensorEntityDescription( key="power_reactive_delivered_to_client", - translation_key="power_reactive_delivered_to_client", + translation_key="power_reactive", + translation_placeholders={"direction": "import"}, device_class=SensorDeviceClass.REACTIVE_POWER, native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, state_class=SensorStateClass.MEASUREMENT, @@ -184,7 +193,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): ), BitvisSensorEntityDescription( key="power_reactive_delivered_by_client", - translation_key="power_reactive_delivered_by_client", + translation_key="power_reactive", + translation_placeholders={"direction": "export"}, device_class=SensorDeviceClass.REACTIVE_POWER, native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, state_class=SensorStateClass.MEASUREMENT, @@ -198,7 +208,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): # Per-phase active power (to client) BitvisSensorEntityDescription( key="power_active_l1_delivered_to_client", - translation_key="power_active_l1_delivered_to_client", + translation_key="power_active_phase", + translation_placeholders={"direction": "import", "phase": "L1"}, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, state_class=SensorStateClass.MEASUREMENT, @@ -211,7 +222,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): ), BitvisSensorEntityDescription( key="power_active_l2_delivered_to_client", - translation_key="power_active_l2_delivered_to_client", + translation_key="power_active_phase", + translation_placeholders={"direction": "import", "phase": "L2"}, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, state_class=SensorStateClass.MEASUREMENT, @@ -224,7 +236,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): ), BitvisSensorEntityDescription( key="power_active_l3_delivered_to_client", - translation_key="power_active_l3_delivered_to_client", + translation_key="power_active_phase", + translation_placeholders={"direction": "import", "phase": "L3"}, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, state_class=SensorStateClass.MEASUREMENT, @@ -238,7 +251,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): # Per-phase active power (by client) BitvisSensorEntityDescription( key="power_active_l1_delivered_by_client", - translation_key="power_active_l1_delivered_by_client", + translation_key="power_active_phase", + translation_placeholders={"direction": "export", "phase": "L1"}, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, state_class=SensorStateClass.MEASUREMENT, @@ -251,7 +265,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): ), BitvisSensorEntityDescription( key="power_active_l2_delivered_by_client", - translation_key="power_active_l2_delivered_by_client", + translation_key="power_active_phase", + translation_placeholders={"direction": "export", "phase": "L2"}, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, state_class=SensorStateClass.MEASUREMENT, @@ -264,7 +279,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): ), BitvisSensorEntityDescription( key="power_active_l3_delivered_by_client", - translation_key="power_active_l3_delivered_by_client", + translation_key="power_active_phase", + translation_placeholders={"direction": "export", "phase": "L3"}, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, state_class=SensorStateClass.MEASUREMENT, @@ -278,7 +294,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): # Per-phase reactive power (to client) BitvisSensorEntityDescription( key="power_reactive_l1_delivered_to_client", - translation_key="power_reactive_l1_delivered_to_client", + translation_key="power_reactive_phase", + translation_placeholders={"direction": "import", "phase": "L1"}, device_class=SensorDeviceClass.REACTIVE_POWER, native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, state_class=SensorStateClass.MEASUREMENT, @@ -291,7 +308,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): ), BitvisSensorEntityDescription( key="power_reactive_l2_delivered_to_client", - translation_key="power_reactive_l2_delivered_to_client", + translation_key="power_reactive_phase", + translation_placeholders={"direction": "import", "phase": "L2"}, device_class=SensorDeviceClass.REACTIVE_POWER, native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, state_class=SensorStateClass.MEASUREMENT, @@ -304,7 +322,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): ), BitvisSensorEntityDescription( key="power_reactive_l3_delivered_to_client", - translation_key="power_reactive_l3_delivered_to_client", + translation_key="power_reactive_phase", + translation_placeholders={"direction": "import", "phase": "L3"}, device_class=SensorDeviceClass.REACTIVE_POWER, native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, state_class=SensorStateClass.MEASUREMENT, @@ -318,7 +337,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): # Per-phase reactive power (by client) BitvisSensorEntityDescription( key="power_reactive_l1_delivered_by_client", - translation_key="power_reactive_l1_delivered_by_client", + translation_key="power_reactive_phase", + translation_placeholders={"direction": "export", "phase": "L1"}, device_class=SensorDeviceClass.REACTIVE_POWER, native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, state_class=SensorStateClass.MEASUREMENT, @@ -331,7 +351,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): ), BitvisSensorEntityDescription( key="power_reactive_l2_delivered_by_client", - translation_key="power_reactive_l2_delivered_by_client", + translation_key="power_reactive_phase", + translation_placeholders={"direction": "export", "phase": "L2"}, device_class=SensorDeviceClass.REACTIVE_POWER, native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, state_class=SensorStateClass.MEASUREMENT, @@ -344,7 +365,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): ), BitvisSensorEntityDescription( key="power_reactive_l3_delivered_by_client", - translation_key="power_reactive_l3_delivered_by_client", + translation_key="power_reactive_phase", + translation_placeholders={"direction": "export", "phase": "L3"}, device_class=SensorDeviceClass.REACTIVE_POWER, native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, state_class=SensorStateClass.MEASUREMENT, @@ -358,7 +380,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): # Energy - active BitvisSensorEntityDescription( key="energy_active_delivered_to_client", - translation_key="energy_active_delivered_to_client", + translation_key="energy_active", + translation_placeholders={"direction": "import"}, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, @@ -371,7 +394,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): ), BitvisSensorEntityDescription( key="energy_active_delivered_by_client", - translation_key="energy_active_delivered_by_client", + translation_key="energy_active", + translation_placeholders={"direction": "export"}, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, @@ -385,7 +409,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): # Energy - reactive BitvisSensorEntityDescription( key="energy_reactive_delivered_to_client", - translation_key="energy_reactive_delivered_to_client", + translation_key="energy_reactive", + translation_placeholders={"direction": "import"}, device_class=SensorDeviceClass.REACTIVE_ENERGY, native_unit_of_measurement=UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, @@ -398,7 +423,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): ), BitvisSensorEntityDescription( key="energy_reactive_delivered_by_client", - translation_key="energy_reactive_delivered_by_client", + translation_key="energy_reactive", + translation_placeholders={"direction": "export"}, device_class=SensorDeviceClass.REACTIVE_ENERGY, native_unit_of_measurement=UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, @@ -413,7 +439,6 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): UPTIME_DESCRIPTION = SensorEntityDescription( key="uptime", - translation_key="uptime", device_class=SensorDeviceClass.UPTIME, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -508,9 +533,10 @@ class BitvisSensorEntity(BitvisBaseSensorEntity): @override def native_value(self) -> float | None: """Return the state of the sensor.""" - if self.coordinator.data.sample is None: - return None - return self.entity_description.value_fn(self.coordinator.data.sample.sample) + payload = self.coordinator.data.sample + if TYPE_CHECKING: + assert payload is not None + return self.entity_description.value_fn(payload.sample) @property @override @@ -528,11 +554,10 @@ class BitvisDiagnosticSensorEntity(BitvisBaseSensorEntity): @override def native_value(self) -> float | int | str | datetime | None: """Return the state of the sensor.""" - if self.coordinator.data.diagnostic is None: - return None - return self.entity_description.value_fn( - self.coordinator.data.diagnostic.diagnostic - ) + payload = self.coordinator.data.diagnostic + if TYPE_CHECKING: + assert payload is not None + return self.entity_description.value_fn(payload.diagnostic) @property @override diff --git a/homeassistant/components/bitvis/strings.json b/homeassistant/components/bitvis/strings.json index 19ecd6717bf772..f8d2c605696031 100644 --- a/homeassistant/components/bitvis/strings.json +++ b/homeassistant/components/bitvis/strings.json @@ -10,12 +10,10 @@ "step": { "user": { "data": { - "host": "[%key:common::config_flow::data::host%]", - "port": "[%key:common::config_flow::data::port%]" + "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "Hostname or IP address of your Bitvis Power Hub device.", - "port": "UDP port the Bitvis Power Hub sends data to." + "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" @@ -28,17 +26,11 @@ }, "entity": { "sensor": { - "energy_active_delivered_by_client": { - "name": "Active energy export" + "energy_active": { + "name": "Active energy {direction}" }, - "energy_active_delivered_to_client": { - "name": "Active energy import" - }, - "energy_reactive_delivered_by_client": { - "name": "Reactive energy export" - }, - "energy_reactive_delivered_to_client": { - "name": "Reactive energy import" + "energy_reactive": { + "name": "Reactive energy {direction}" }, "han_msg_buffer_overflow": { "name": "HAN buffer overflows" @@ -46,74 +38,23 @@ "han_msg_successfully_parsed": { "name": "HAN messages successfully parsed" }, - "phase_current_l1": { - "name": "Current L1" - }, - "phase_current_l2": { - "name": "Current L2" - }, - "phase_current_l3": { - "name": "Current L3" - }, - "phase_voltage_l1": { - "name": "Voltage L1" - }, - "phase_voltage_l2": { - "name": "Voltage L2" - }, - "phase_voltage_l3": { - "name": "Voltage L3" - }, - "power_active_delivered_by_client": { - "name": "Active power export" - }, - "power_active_delivered_to_client": { - "name": "Active power import" - }, - "power_active_l1_delivered_by_client": { - "name": "Active power export L1" - }, - "power_active_l1_delivered_to_client": { - "name": "Active power import L1" - }, - "power_active_l2_delivered_by_client": { - "name": "Active power export L2" - }, - "power_active_l2_delivered_to_client": { - "name": "Active power import L2" - }, - "power_active_l3_delivered_by_client": { - "name": "Active power export L3" - }, - "power_active_l3_delivered_to_client": { - "name": "Active power import L3" - }, - "power_reactive_delivered_by_client": { - "name": "Reactive power export" - }, - "power_reactive_delivered_to_client": { - "name": "Reactive power import" - }, - "power_reactive_l1_delivered_by_client": { - "name": "Reactive power export L1" - }, - "power_reactive_l1_delivered_to_client": { - "name": "Reactive power import L1" + "phase_current": { + "name": "Current {phase}" }, - "power_reactive_l2_delivered_by_client": { - "name": "Reactive power export L2" + "phase_voltage": { + "name": "Voltage {phase}" }, - "power_reactive_l2_delivered_to_client": { - "name": "Reactive power import L2" + "power_active": { + "name": "Active power {direction}" }, - "power_reactive_l3_delivered_by_client": { - "name": "Reactive power export L3" + "power_active_phase": { + "name": "Active power {direction} {phase}" }, - "power_reactive_l3_delivered_to_client": { - "name": "Reactive power import L3" + "power_reactive": { + "name": "Reactive power {direction}" }, - "uptime": { - "name": "Uptime" + "power_reactive_phase": { + "name": "Reactive power {direction} {phase}" }, "wifi_rssi": { "name": "Wi-Fi signal strength" diff --git a/tests/components/bitvis/snapshots/test_sensor.ambr b/tests/components/bitvis/snapshots/test_sensor.ambr index 072ff3e35f1843..fab0eb02f11876 100644 --- a/tests/components/bitvis/snapshots/test_sensor.ambr +++ b/tests/components/bitvis/snapshots/test_sensor.ambr @@ -36,7 +36,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'energy_active_delivered_by_client', + 'translation_key': 'energy_active', 'unique_id': 'aa:bb:cc:dd:ee:ff_energy_active_delivered_by_client', 'unit_of_measurement': , }) @@ -94,7 +94,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'energy_active_delivered_to_client', + 'translation_key': 'energy_active', 'unique_id': 'aa:bb:cc:dd:ee:ff_energy_active_delivered_to_client', 'unit_of_measurement': , }) @@ -152,7 +152,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_active_delivered_by_client', + 'translation_key': 'power_active', 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_delivered_by_client', 'unit_of_measurement': , }) @@ -210,7 +210,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_active_l1_delivered_by_client', + 'translation_key': 'power_active_phase', 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_l1_delivered_by_client', 'unit_of_measurement': , }) @@ -268,7 +268,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_active_l2_delivered_by_client', + 'translation_key': 'power_active_phase', 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_l2_delivered_by_client', 'unit_of_measurement': , }) @@ -326,7 +326,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_active_l3_delivered_by_client', + 'translation_key': 'power_active_phase', 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_l3_delivered_by_client', 'unit_of_measurement': , }) @@ -384,7 +384,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_active_delivered_to_client', + 'translation_key': 'power_active', 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_delivered_to_client', 'unit_of_measurement': , }) @@ -442,7 +442,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_active_l1_delivered_to_client', + 'translation_key': 'power_active_phase', 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_l1_delivered_to_client', 'unit_of_measurement': , }) @@ -500,7 +500,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_active_l2_delivered_to_client', + 'translation_key': 'power_active_phase', 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_l2_delivered_to_client', 'unit_of_measurement': , }) @@ -558,7 +558,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_active_l3_delivered_to_client', + 'translation_key': 'power_active_phase', 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_l3_delivered_to_client', 'unit_of_measurement': , }) @@ -616,7 +616,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'phase_current_l1', + 'translation_key': 'phase_current', 'unique_id': 'aa:bb:cc:dd:ee:ff_phase_current_l1', 'unit_of_measurement': , }) @@ -674,7 +674,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'phase_current_l2', + 'translation_key': 'phase_current', 'unique_id': 'aa:bb:cc:dd:ee:ff_phase_current_l2', 'unit_of_measurement': , }) @@ -732,7 +732,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'phase_current_l3', + 'translation_key': 'phase_current', 'unique_id': 'aa:bb:cc:dd:ee:ff_phase_current_l3', 'unit_of_measurement': , }) @@ -896,7 +896,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'energy_reactive_delivered_by_client', + 'translation_key': 'energy_reactive', 'unique_id': 'aa:bb:cc:dd:ee:ff_energy_reactive_delivered_by_client', 'unit_of_measurement': , }) @@ -954,7 +954,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'energy_reactive_delivered_to_client', + 'translation_key': 'energy_reactive', 'unique_id': 'aa:bb:cc:dd:ee:ff_energy_reactive_delivered_to_client', 'unit_of_measurement': , }) @@ -1012,7 +1012,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_reactive_delivered_by_client', + 'translation_key': 'power_reactive', 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_delivered_by_client', 'unit_of_measurement': , }) @@ -1070,7 +1070,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_reactive_l1_delivered_by_client', + 'translation_key': 'power_reactive_phase', 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_l1_delivered_by_client', 'unit_of_measurement': , }) @@ -1128,7 +1128,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_reactive_l2_delivered_by_client', + 'translation_key': 'power_reactive_phase', 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_l2_delivered_by_client', 'unit_of_measurement': , }) @@ -1186,7 +1186,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_reactive_l3_delivered_by_client', + 'translation_key': 'power_reactive_phase', 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_l3_delivered_by_client', 'unit_of_measurement': , }) @@ -1244,7 +1244,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_reactive_delivered_to_client', + 'translation_key': 'power_reactive', 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_delivered_to_client', 'unit_of_measurement': , }) @@ -1302,7 +1302,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_reactive_l1_delivered_to_client', + 'translation_key': 'power_reactive_phase', 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_l1_delivered_to_client', 'unit_of_measurement': , }) @@ -1360,7 +1360,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_reactive_l2_delivered_to_client', + 'translation_key': 'power_reactive_phase', 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_l2_delivered_to_client', 'unit_of_measurement': , }) @@ -1418,7 +1418,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_reactive_l3_delivered_to_client', + 'translation_key': 'power_reactive_phase', 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_l3_delivered_to_client', 'unit_of_measurement': , }) @@ -1471,7 +1471,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'uptime', + 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff_uptime', 'unit_of_measurement': None, }) @@ -1527,7 +1527,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'phase_voltage_l1', + 'translation_key': 'phase_voltage', 'unique_id': 'aa:bb:cc:dd:ee:ff_phase_voltage_l1', 'unit_of_measurement': , }) @@ -1585,7 +1585,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'phase_voltage_l2', + 'translation_key': 'phase_voltage', 'unique_id': 'aa:bb:cc:dd:ee:ff_phase_voltage_l2', 'unit_of_measurement': , }) @@ -1643,7 +1643,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'phase_voltage_l3', + 'translation_key': 'phase_voltage', 'unique_id': 'aa:bb:cc:dd:ee:ff_phase_voltage_l3', 'unit_of_measurement': , }) diff --git a/tests/components/bitvis/test_config_flow.py b/tests/components/bitvis/test_config_flow.py index 52a327a84b40a7..7d33133685c6c9 100644 --- a/tests/components/bitvis/test_config_flow.py +++ b/tests/components/bitvis/test_config_flow.py @@ -19,8 +19,6 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from .conftest import TEST_DEVICE_MAC - from tests.common import MockConfigEntry ZEROCONF_DISCOVERY = ZeroconfServiceInfo( @@ -34,16 +32,6 @@ ) -@pytest.fixture(autouse=True) -def mock_get_mac_address_for_host() -> None: - """Mock MAC address lookup for config flow tests.""" - with patch( - "homeassistant.components.bitvis.config_flow.get_mac_address_for_host", - return_value=TEST_DEVICE_MAC, - ): - yield - - async def test_user_form(hass: HomeAssistant) -> None: """Test we get the user form.""" result = await hass.config_entries.flow.async_init( @@ -73,7 +61,6 @@ async def test_user_form_create_entry(hass: HomeAssistant) -> None: result["flow_id"], { CONF_HOST: "192.168.1.100", - CONF_PORT: 5000, }, ) @@ -81,9 +68,9 @@ async def test_user_form_create_entry(hass: HomeAssistant) -> None: assert result["title"] == MODEL_NAME assert result["data"] == { CONF_HOST: "192.168.1.100", - CONF_PORT: 5000, + CONF_PORT: DEFAULT_PORT, } - assert hass.config_entries.async_entries(DOMAIN)[0].unique_id == TEST_DEVICE_MAC + assert hass.config_entries.async_entries(DOMAIN)[0].unique_id == DOMAIN @pytest.mark.parametrize("recover", [False, True]) @@ -101,7 +88,6 @@ async def test_user_form_cannot_connect(hass: HomeAssistant, recover: bool) -> N result["flow_id"], { CONF_HOST: "192.168.1.100", - CONF_PORT: 5000, }, ) @@ -123,7 +109,6 @@ async def test_user_form_cannot_connect(hass: HomeAssistant, recover: bool) -> N result["flow_id"], { CONF_HOST: "192.168.1.100", - CONF_PORT: 5000, }, ) @@ -131,7 +116,7 @@ async def test_user_form_cannot_connect(hass: HomeAssistant, recover: bool) -> N assert result["title"] == MODEL_NAME assert result["data"] == { CONF_HOST: "192.168.1.100", - CONF_PORT: 5000, + CONF_PORT: DEFAULT_PORT, } @@ -141,9 +126,8 @@ async def test_user_form_duplicate(hass: HomeAssistant) -> None: domain=DOMAIN, data={ CONF_HOST: "192.168.1.100", - CONF_PORT: 5000, + CONF_PORT: DEFAULT_PORT, }, - unique_id=TEST_DEVICE_MAC, ) entry.add_to_hass(hass) @@ -159,7 +143,6 @@ async def test_user_form_duplicate(hass: HomeAssistant) -> None: result["flow_id"], { CONF_HOST: "192.168.1.100", - CONF_PORT: 5000, }, ) @@ -196,6 +179,7 @@ async def test_zeroconf_confirm_creates_entry(hass: HomeAssistant) -> None: 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]) @@ -253,7 +237,6 @@ async def test_zeroconf_duplicate(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "192.168.1.200", CONF_PORT: DEFAULT_PORT}, - unique_id=TEST_DEVICE_MAC, ) entry.add_to_hass(hass) @@ -323,7 +306,6 @@ async def test_user_form_create_entry_ipv6_host(hass: HomeAssistant) -> None: result["flow_id"], { CONF_HOST: "2001:db8::10", - CONF_PORT: 5000, }, ) @@ -331,21 +313,20 @@ async def test_user_form_create_entry_ipv6_host(hass: HomeAssistant) -> None: assert result["title"] == MODEL_NAME assert result["data"] == { CONF_HOST: "2001:db8::10", - CONF_PORT: 5000, + CONF_PORT: DEFAULT_PORT, } assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert hass.config_entries.async_entries(DOMAIN)[0].unique_id == TEST_DEVICE_MAC + assert hass.config_entries.async_entries(DOMAIN)[0].unique_id == DOMAIN -async def test_user_form_duplicate_mac(hass: HomeAssistant) -> None: - """Test duplicate detection uses the device MAC address.""" +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: 5000, + CONF_PORT: DEFAULT_PORT, }, - unique_id=TEST_DEVICE_MAC, ) entry.add_to_hass(hass) @@ -361,7 +342,6 @@ async def test_user_form_duplicate_mac(hass: HomeAssistant) -> None: result["flow_id"], { CONF_HOST: "2001:db8::10", - CONF_PORT: 5001, }, ) @@ -369,10 +349,10 @@ async def test_user_form_duplicate_mac(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_user_form_resolve_host_failure_uses_host_for_mac_lookup( +async def test_user_form_keeps_hostname( hass: HomeAssistant, ) -> None: - """Test that user flow falls back to raw host when DNS resolution fails.""" + """Test that user flow keeps the configured hostname.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -391,14 +371,12 @@ async def test_user_form_resolve_host_failure_uses_host_for_mac_lookup( result["flow_id"], { CONF_HOST: "my-powerhub.local", - CONF_PORT: 5000, }, ) assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == MODEL_NAME assert result["data"][CONF_HOST] == "my-powerhub.local" - assert hass.config_entries.async_entries(DOMAIN)[0].unique_id == TEST_DEVICE_MAC async def test_user_form_normalize_bracketed_ipv6( @@ -423,7 +401,6 @@ async def test_user_form_normalize_bracketed_ipv6( result["flow_id"], { CONF_HOST: "[2001:db8::10]", - CONF_PORT: 5000, }, ) From 87ec6a01e1525629f6e5d9c587211df4b9768858 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mandus=20B=C3=B6rjesson?= Date: Mon, 17 Aug 2026 07:44:21 +0200 Subject: [PATCH 3/4] fixup! fixup! Add Bitvis Power Hub integration --- homeassistant/components/bitvis/sensor.py | 72 +++++------ homeassistant/components/bitvis/strings.json | 42 ++++-- .../bitvis/snapshots/test_sensor.ambr | 122 +++++++++--------- 3 files changed, 123 insertions(+), 113 deletions(-) diff --git a/homeassistant/components/bitvis/sensor.py b/homeassistant/components/bitvis/sensor.py index 7bfad08aa21e66..91e0ada46a6660 100644 --- a/homeassistant/components/bitvis/sensor.py +++ b/homeassistant/components/bitvis/sensor.py @@ -150,8 +150,7 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): # Total active power BitvisSensorEntityDescription( key="power_active_delivered_to_client", - translation_key="power_active", - translation_placeholders={"direction": "import"}, + translation_key="power_active_import", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, state_class=SensorStateClass.MEASUREMENT, @@ -164,8 +163,7 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): ), BitvisSensorEntityDescription( key="power_active_delivered_by_client", - translation_key="power_active", - translation_placeholders={"direction": "export"}, + translation_key="power_active_export", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, state_class=SensorStateClass.MEASUREMENT, @@ -179,8 +177,7 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): # Total reactive power BitvisSensorEntityDescription( key="power_reactive_delivered_to_client", - translation_key="power_reactive", - translation_placeholders={"direction": "import"}, + translation_key="power_reactive_import", device_class=SensorDeviceClass.REACTIVE_POWER, native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, state_class=SensorStateClass.MEASUREMENT, @@ -193,8 +190,7 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): ), BitvisSensorEntityDescription( key="power_reactive_delivered_by_client", - translation_key="power_reactive", - translation_placeholders={"direction": "export"}, + translation_key="power_reactive_export", device_class=SensorDeviceClass.REACTIVE_POWER, native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE, state_class=SensorStateClass.MEASUREMENT, @@ -208,8 +204,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): # Per-phase active power (to client) BitvisSensorEntityDescription( key="power_active_l1_delivered_to_client", - translation_key="power_active_phase", - translation_placeholders={"direction": "import", "phase": "L1"}, + 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, @@ -222,8 +218,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): ), BitvisSensorEntityDescription( key="power_active_l2_delivered_to_client", - translation_key="power_active_phase", - translation_placeholders={"direction": "import", "phase": "L2"}, + 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, @@ -236,8 +232,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): ), BitvisSensorEntityDescription( key="power_active_l3_delivered_to_client", - translation_key="power_active_phase", - translation_placeholders={"direction": "import", "phase": "L3"}, + 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, @@ -251,8 +247,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): # Per-phase active power (by client) BitvisSensorEntityDescription( key="power_active_l1_delivered_by_client", - translation_key="power_active_phase", - translation_placeholders={"direction": "export", "phase": "L1"}, + 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, @@ -265,8 +261,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): ), BitvisSensorEntityDescription( key="power_active_l2_delivered_by_client", - translation_key="power_active_phase", - translation_placeholders={"direction": "export", "phase": "L2"}, + 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, @@ -279,8 +275,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): ), BitvisSensorEntityDescription( key="power_active_l3_delivered_by_client", - translation_key="power_active_phase", - translation_placeholders={"direction": "export", "phase": "L3"}, + 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, @@ -294,8 +290,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): # Per-phase reactive power (to client) BitvisSensorEntityDescription( key="power_reactive_l1_delivered_to_client", - translation_key="power_reactive_phase", - translation_placeholders={"direction": "import", "phase": "L1"}, + 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, @@ -308,8 +304,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): ), BitvisSensorEntityDescription( key="power_reactive_l2_delivered_to_client", - translation_key="power_reactive_phase", - translation_placeholders={"direction": "import", "phase": "L2"}, + 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, @@ -322,8 +318,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): ), BitvisSensorEntityDescription( key="power_reactive_l3_delivered_to_client", - translation_key="power_reactive_phase", - translation_placeholders={"direction": "import", "phase": "L3"}, + 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, @@ -337,8 +333,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): # Per-phase reactive power (by client) BitvisSensorEntityDescription( key="power_reactive_l1_delivered_by_client", - translation_key="power_reactive_phase", - translation_placeholders={"direction": "export", "phase": "L1"}, + 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, @@ -351,8 +347,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): ), BitvisSensorEntityDescription( key="power_reactive_l2_delivered_by_client", - translation_key="power_reactive_phase", - translation_placeholders={"direction": "export", "phase": "L2"}, + 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, @@ -365,8 +361,8 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): ), BitvisSensorEntityDescription( key="power_reactive_l3_delivered_by_client", - translation_key="power_reactive_phase", - translation_placeholders={"direction": "export", "phase": "L3"}, + 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, @@ -380,8 +376,7 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): # Energy - active BitvisSensorEntityDescription( key="energy_active_delivered_to_client", - translation_key="energy_active", - translation_placeholders={"direction": "import"}, + translation_key="energy_active_import", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, @@ -394,8 +389,7 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): ), BitvisSensorEntityDescription( key="energy_active_delivered_by_client", - translation_key="energy_active", - translation_placeholders={"direction": "export"}, + translation_key="energy_active_export", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, @@ -409,8 +403,7 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): # Energy - reactive BitvisSensorEntityDescription( key="energy_reactive_delivered_to_client", - translation_key="energy_reactive", - translation_placeholders={"direction": "import"}, + 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, @@ -423,8 +416,7 @@ class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription): ), BitvisSensorEntityDescription( key="energy_reactive_delivered_by_client", - translation_key="energy_reactive", - translation_placeholders={"direction": "export"}, + 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, diff --git a/homeassistant/components/bitvis/strings.json b/homeassistant/components/bitvis/strings.json index f8d2c605696031..b9619717566f43 100644 --- a/homeassistant/components/bitvis/strings.json +++ b/homeassistant/components/bitvis/strings.json @@ -26,11 +26,17 @@ }, "entity": { "sensor": { - "energy_active": { - "name": "Active energy {direction}" + "energy_active_export": { + "name": "Active energy export" }, - "energy_reactive": { - "name": "Reactive energy {direction}" + "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" @@ -44,17 +50,29 @@ "phase_voltage": { "name": "Voltage {phase}" }, - "power_active": { - "name": "Active power {direction}" + "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_active_phase": { - "name": "Active power {direction} {phase}" + "power_reactive_import": { + "name": "Reactive power import" }, - "power_reactive": { - "name": "Reactive power {direction}" + "power_reactive_phase_export": { + "name": "Reactive power export {phase}" }, - "power_reactive_phase": { - "name": "Reactive power {direction} {phase}" + "power_reactive_phase_import": { + "name": "Reactive power import {phase}" }, "wifi_rssi": { "name": "Wi-Fi signal strength" diff --git a/tests/components/bitvis/snapshots/test_sensor.ambr b/tests/components/bitvis/snapshots/test_sensor.ambr index fab0eb02f11876..61a02a746ad9c2 100644 --- a/tests/components/bitvis/snapshots/test_sensor.ambr +++ b/tests/components/bitvis/snapshots/test_sensor.ambr @@ -36,7 +36,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'energy_active', + 'translation_key': 'energy_active_export', 'unique_id': 'aa:bb:cc:dd:ee:ff_energy_active_delivered_by_client', 'unit_of_measurement': , }) @@ -94,7 +94,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'energy_active', + 'translation_key': 'energy_active_import', 'unique_id': 'aa:bb:cc:dd:ee:ff_energy_active_delivered_to_client', 'unit_of_measurement': , }) @@ -152,7 +152,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_active', + 'translation_key': 'power_active_export', 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_delivered_by_client', 'unit_of_measurement': , }) @@ -210,7 +210,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_active_phase', + 'translation_key': 'power_active_phase_export', 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_l1_delivered_by_client', 'unit_of_measurement': , }) @@ -231,7 +231,7 @@ 'state': '0.0', }) # --- -# name: test_all_entities[sensor.power_hub_active_power_export_l2-entry] +# name: test_all_entities[sensor.power_hub_active_power_export_l3-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -247,7 +247,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.power_hub_active_power_export_l2', + 'entity_id': 'sensor.power_hub_active_power_export_l3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -255,7 +255,7 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Active power export L2', + 'object_id_base': 'Active power export L3', 'options': dict({ 'sensor': dict({ 'suggested_display_precision': 3, @@ -263,33 +263,33 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Active power export L2', + 'original_name': 'Active power export L3', 'platform': 'bitvis', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_active_phase', - 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_l2_delivered_by_client', + '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_l2-state] +# name: test_all_entities[sensor.power_hub_active_power_export_l3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'power', - : 'Power Hub Active power export L2', + : 'Power Hub Active power export L3', : , : , }), 'context': , - 'entity_id': 'sensor.power_hub_active_power_export_l2', + '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_export_l3-entry] +# name: test_all_entities[sensor.power_hub_active_power_import-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -305,7 +305,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.power_hub_active_power_export_l3', + 'entity_id': 'sensor.power_hub_active_power_import', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -313,7 +313,7 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Active power export L3', + 'object_id_base': 'Active power import', 'options': dict({ 'sensor': dict({ 'suggested_display_precision': 3, @@ -321,33 +321,33 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Active power export L3', + 'original_name': 'Active power import', 'platform': 'bitvis', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_active_phase', - 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_l3_delivered_by_client', + '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_export_l3-state] +# name: test_all_entities[sensor.power_hub_active_power_import-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'power', - : 'Power Hub Active power export L3', + : 'Power Hub Active power import', : , : , }), 'context': , - 'entity_id': 'sensor.power_hub_active_power_export_l3', + 'entity_id': 'sensor.power_hub_active_power_import', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': '2.41499996185303', }) # --- -# name: test_all_entities[sensor.power_hub_active_power_import-entry] +# name: test_all_entities[sensor.power_hub_active_power_import_l1-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -363,7 +363,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.power_hub_active_power_import', + 'entity_id': 'sensor.power_hub_active_power_import_l1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -371,7 +371,7 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Active power import', + 'object_id_base': 'Active power import L1', 'options': dict({ 'sensor': dict({ 'suggested_display_precision': 3, @@ -379,33 +379,33 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Active power import', + 'original_name': 'Active power import L1', 'platform': 'bitvis', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_active', - 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_delivered_to_client', + '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-state] +# name: test_all_entities[sensor.power_hub_active_power_import_l1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'power', - : 'Power Hub Active power import', + : 'Power Hub Active power import L1', : , : , }), 'context': , - 'entity_id': 'sensor.power_hub_active_power_import', + 'entity_id': 'sensor.power_hub_active_power_import_l1', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2.41499996185303', + 'state': '0.800000011920929', }) # --- -# name: test_all_entities[sensor.power_hub_active_power_import_l1-entry] +# name: test_all_entities[sensor.power_hub_active_power_import_l2-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -421,7 +421,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.power_hub_active_power_import_l1', + 'entity_id': 'sensor.power_hub_active_power_import_l2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -429,7 +429,7 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Active power import L1', + 'object_id_base': 'Active power import L2', 'options': dict({ 'sensor': dict({ 'suggested_display_precision': 3, @@ -437,33 +437,33 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Active power import L1', + 'original_name': 'Active power import L2', 'platform': 'bitvis', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_active_phase', - 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_l1_delivered_to_client', + '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_l1-state] +# name: test_all_entities[sensor.power_hub_active_power_import_l2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'power', - : 'Power Hub Active power import L1', + : 'Power Hub Active power import L2', : , : , }), 'context': , - 'entity_id': 'sensor.power_hub_active_power_import_l1', + 'entity_id': 'sensor.power_hub_active_power_import_l2', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.800000011920929', + 'state': '0.699999988079071', }) # --- -# name: test_all_entities[sensor.power_hub_active_power_import_l2-entry] +# name: test_all_entities[sensor.power_hub_active_power_import_l2_2-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -479,7 +479,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.power_hub_active_power_import_l2', + 'entity_id': 'sensor.power_hub_active_power_import_l2_2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -500,12 +500,12 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_active_phase', - 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_l2_delivered_to_client', + '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-state] +# name: test_all_entities[sensor.power_hub_active_power_import_l2_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'power', @@ -514,11 +514,11 @@ : , }), 'context': , - 'entity_id': 'sensor.power_hub_active_power_import_l2', + 'entity_id': 'sensor.power_hub_active_power_import_l2_2', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.699999988079071', + 'state': '0.0', }) # --- # name: test_all_entities[sensor.power_hub_active_power_import_l3-entry] @@ -558,7 +558,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_active_phase', + 'translation_key': 'power_active_phase_import', 'unique_id': 'aa:bb:cc:dd:ee:ff_power_active_l3_delivered_to_client', 'unit_of_measurement': , }) @@ -896,7 +896,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'energy_reactive', + 'translation_key': 'energy_reactive_export', 'unique_id': 'aa:bb:cc:dd:ee:ff_energy_reactive_delivered_by_client', 'unit_of_measurement': , }) @@ -954,7 +954,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'energy_reactive', + 'translation_key': 'energy_reactive_import', 'unique_id': 'aa:bb:cc:dd:ee:ff_energy_reactive_delivered_to_client', 'unit_of_measurement': , }) @@ -1012,7 +1012,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_reactive', + 'translation_key': 'power_reactive_export', 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_delivered_by_client', 'unit_of_measurement': , }) @@ -1070,7 +1070,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_reactive_phase', + 'translation_key': 'power_reactive_phase_export', 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_l1_delivered_by_client', 'unit_of_measurement': , }) @@ -1128,7 +1128,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_reactive_phase', + 'translation_key': 'power_reactive_phase_export', 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_l2_delivered_by_client', 'unit_of_measurement': , }) @@ -1186,7 +1186,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_reactive_phase', + 'translation_key': 'power_reactive_phase_export', 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_l3_delivered_by_client', 'unit_of_measurement': , }) @@ -1244,7 +1244,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_reactive', + 'translation_key': 'power_reactive_import', 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_delivered_to_client', 'unit_of_measurement': , }) @@ -1302,7 +1302,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_reactive_phase', + 'translation_key': 'power_reactive_phase_import', 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_l1_delivered_to_client', 'unit_of_measurement': , }) @@ -1360,7 +1360,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_reactive_phase', + 'translation_key': 'power_reactive_phase_import', 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_l2_delivered_to_client', 'unit_of_measurement': , }) @@ -1418,7 +1418,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'power_reactive_phase', + 'translation_key': 'power_reactive_phase_import', 'unique_id': 'aa:bb:cc:dd:ee:ff_power_reactive_l3_delivered_to_client', 'unit_of_measurement': , }) From b1509777052aa9ade0653f0847405c53f6599b85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mandus=20B=C3=B6rjesson?= Date: Wed, 19 Aug 2026 11:42:09 +0200 Subject: [PATCH 4/4] fixup! fixup! fixup! Add Bitvis Power Hub integration --- homeassistant/components/bitvis/__init__.py | 10 +- .../components/bitvis/config_flow.py | 110 ++++++++--- homeassistant/components/bitvis/const.py | 1 + .../components/bitvis/coordinator.py | 24 +-- homeassistant/components/bitvis/manifest.json | 2 +- .../components/bitvis/quality_scale.yaml | 2 +- homeassistant/components/bitvis/sensor.py | 39 ++-- homeassistant/components/bitvis/strings.json | 6 +- requirements_all.txt | 2 +- tests/components/bitvis/test_config_flow.py | 184 ++++++++++++++++-- tests/components/bitvis/test_coordinator.py | 56 ++++-- tests/components/bitvis/test_sensor.py | 80 ++++++-- 12 files changed, 423 insertions(+), 93 deletions(-) diff --git a/homeassistant/components/bitvis/__init__.py b/homeassistant/components/bitvis/__init__.py index 9480b35af1325a..a28168a5520ebb 100644 --- a/homeassistant/components/bitvis/__init__.py +++ b/homeassistant/components/bitvis/__init__.py @@ -14,8 +14,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: BitvisConfigEntry) -> bool: """Set up Bitvis Power Hub from a config entry.""" + if entry.unique_id is None: + msg = "Config entry is missing unique_id (MAC address)" + raise ValueError(msg) + coordinator = BitvisDataUpdateCoordinator( - hass, entry, entry.data[CONF_HOST], entry.data[CONF_PORT] + hass, + entry, + entry.data[CONF_HOST], + entry.data[CONF_PORT], + entry.unique_id, ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/bitvis/config_flow.py b/homeassistant/components/bitvis/config_flow.py index 4cb575cb65af24..97463c3a6dbe63 100644 --- a/homeassistant/components/bitvis/config_flow.py +++ b/homeassistant/components/bitvis/config_flow.py @@ -1,24 +1,30 @@ """Config flow for the Bitvis Power Hub integration.""" +import asyncio import logging from typing import Any, override -from bitvis_protobuf.utils import async_verify_udp_port_bindable, normalize_host +from bitvis_protobuf.listener import FilterIp +from bitvis_protobuf.parse import PayloadDiagnostic, PayloadSample +from bitvis_protobuf.utils import ( + async_resolve_host, + async_verify_udp_port_bindable, + normalize_host, +) import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN, MODEL_NAME +from .const import DEFAULT_NAME, DEFAULT_PORT, DISCOVERY_TIMEOUT, 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.""" @@ -29,6 +35,35 @@ async def _async_test_port(hass: HomeAssistant, port: int) -> None: await async_verify_udp_port_bindable(port) +async def _async_discover_mac_address(hass: HomeAssistant, host: str, port: int) -> str: + """Wait for a UDP message from the device and return its MAC address.""" + resolved_ips = await async_resolve_host(host) + listener_registry = async_get_listener_registry(hass) + listener = await listener_registry.async_get_or_create(port) + + loop = asyncio.get_running_loop() + future: asyncio.Future[str] = loop.create_future() + + @callback + def _on_payload( + payload: PayloadSample | PayloadDiagnostic, _addr: tuple[str, int] + ) -> None: + if not future.done(): + future.set_result(payload.mac_address) + + filters: list[FilterIp] = [] + try: + for ip in resolved_ips: + filt = FilterIp(ip) + listener.register(filt, _on_payload) + filters.append(filt) + + return await asyncio.wait_for(future, timeout=DISCOVERY_TIMEOUT) + finally: + for filt in filters: + listener.unregister(filt) + + class BitvisConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Bitvis Power Hub.""" @@ -43,32 +78,48 @@ def _get_friendly_name(self, name: str | None) -> str: instance = name.split(".", 1)[0] return instance or DEFAULT_NAME + async def _async_create_entry_from_host( + self, host: str, title: str + ) -> ConfigFlowResult: + """Validate connectivity, discover MAC address, and create the entry.""" + try: + await _async_test_port(self.hass, DEFAULT_PORT) + mac_address = await _async_discover_mac_address( + self.hass, host, DEFAULT_PORT + ) + except TimeoutError: + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_HOST): cv.string}), + errors={"base": "timeout_connect"}, + ) + except OSError: + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_HOST): cv.string}), + errors={"base": "cannot_connect"}, + ) + + await self.async_set_unique_id(format_mac(mac_address)) + self._abort_if_unique_id_configured() + self._async_abort_entries_match({CONF_HOST: host}) + + return self.async_create_entry( + title=title, + data={ + CONF_HOST: host, + CONF_PORT: DEFAULT_PORT, + }, + ) + @override async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - 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, - }, - ) + return await self._async_create_entry_from_host(host, MODEL_NAME) data_schema = vol.Schema( { @@ -79,7 +130,6 @@ async def async_step_user( return self.async_show_form( step_id="user", data_schema=data_schema, - errors=errors, ) @override @@ -91,8 +141,6 @@ async def async_step_zeroconf( 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 @@ -115,9 +163,17 @@ async def async_step_zeroconf_confirm( try: await _async_test_port(self.hass, DEFAULT_PORT) + mac_address = await _async_discover_mac_address( + self.hass, host, DEFAULT_PORT + ) + except TimeoutError: + return self.async_abort(reason="timeout_connect") except OSError: return self.async_abort(reason="cannot_connect") + await self.async_set_unique_id(format_mac(mac_address)) + self._abort_if_unique_id_configured() + return self.async_create_entry( title=self._get_friendly_name(self._discovery_info.name), data={ diff --git a/homeassistant/components/bitvis/const.py b/homeassistant/components/bitvis/const.py index 94ec246afd86db..f8128614d64762 100644 --- a/homeassistant/components/bitvis/const.py +++ b/homeassistant/components/bitvis/const.py @@ -15,5 +15,6 @@ DEFAULT_NAME = "Bitvis Power Hub" DEFAULT_PORT = 58220 +DISCOVERY_TIMEOUT = 30 DATA_LISTENER_REGISTRY: HassKey[BitvisListenerRegistry] = HassKey(DOMAIN) diff --git a/homeassistant/components/bitvis/coordinator.py b/homeassistant/components/bitvis/coordinator.py index 92b9100edad162..47ae1a591a7a14 100644 --- a/homeassistant/components/bitvis/coordinator.py +++ b/homeassistant/components/bitvis/coordinator.py @@ -6,9 +6,8 @@ import logging from typing import override -from bitvis_protobuf.listener import SharedListener +from bitvis_protobuf.listener import FilterMac, 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 @@ -93,7 +92,12 @@ class BitvisDataUpdateCoordinator(DataUpdateCoordinator[BitvisData]): """Coordinator to manage data updates from UDP packets.""" def __init__( - self, hass: HomeAssistant, config_entry: BitvisConfigEntry, host: str, port: int + self, + hass: HomeAssistant, + config_entry: BitvisConfigEntry, + host: str, + port: int, + mac_address: str, ) -> None: """Initialize the coordinator.""" super().__init__( @@ -104,7 +108,8 @@ def __init__( ) self.host = host self.port = port - self._registered_ips: set[str] = set() + self.mac_address = mac_address + self._filter = FilterMac(mac_address) self._stable_boot_time = ignore_variance( _uptime_to_boot_time, timedelta(minutes=5) ) @@ -114,11 +119,10 @@ def __init__( 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: + listener.register(self._filter, self._handle_payload) + except OSError as err: await self.async_stop() raise UpdateFailed( f"Failed to start UDP listener on port {self.port}" @@ -133,10 +137,9 @@ 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) + listener.unregister(self._filter) 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 ) @@ -165,13 +168,12 @@ def _handle_diagnostic(self, payload: PayloadDiagnostic) -> None: """Update diagnostic data and notify listeners.""" self.data.diagnostic = payload diagnostic = payload.diagnostic + self.data.mac_address = payload.mac_address 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) diff --git a/homeassistant/components/bitvis/manifest.json b/homeassistant/components/bitvis/manifest.json index ce9af18dfe10af..86484f3b7469bb 100644 --- a/homeassistant/components/bitvis/manifest.json +++ b/homeassistant/components/bitvis/manifest.json @@ -8,6 +8,6 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["bitvis-protobuf==0.2.7"], + "requirements": ["bitvis-protobuf==1.0.1"], "zeroconf": ["_powerhub._udp.local."] } diff --git a/homeassistant/components/bitvis/quality_scale.yaml b/homeassistant/components/bitvis/quality_scale.yaml index 6f67935a45085a..d1a9448f7f55aa 100644 --- a/homeassistant/components/bitvis/quality_scale.yaml +++ b/homeassistant/components/bitvis/quality_scale.yaml @@ -57,7 +57,7 @@ rules: docs-supported-functions: todo docs-troubleshooting: todo docs-use-cases: todo - dynamic-devices: todo + dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: todo diff --git a/homeassistant/components/bitvis/sensor.py b/homeassistant/components/bitvis/sensor.py index 91e0ada46a6660..416cf5f82d2674 100644 --- a/homeassistant/components/bitvis/sensor.py +++ b/homeassistant/components/bitvis/sensor.py @@ -24,7 +24,7 @@ UnitOfReactiveEnergy, UnitOfReactivePower, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -473,19 +473,34 @@ async def async_setup_entry( ) -> 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 + known_keys: set[str] = set() + + async_add_entities( + [ + BitvisUptimeSensorEntity(coordinator, UPTIME_DESCRIPTION, entry), + *( + BitvisDiagnosticSensorEntity(coordinator, description, entry) + for description in DIAGNOSTIC_SENSOR_DESCRIPTIONS + ), + ] ) - async_add_entities(entities) + @callback + def _check_entities() -> None: + if (payload := coordinator.data.sample) is None: + return + entities = [ + BitvisSensorEntity(coordinator, description, entry) + for description in SENSOR_DESCRIPTIONS + if description.key not in known_keys + and description.value_fn(payload.sample) is not None + ] + if entities: + known_keys.update(entity.entity_description.key for entity in entities) + async_add_entities(entities) + + _check_entities() + entry.async_on_unload(coordinator.async_add_listener(_check_entities)) class BitvisBaseSensorEntity( diff --git a/homeassistant/components/bitvis/strings.json b/homeassistant/components/bitvis/strings.json index b9619717566f43..53c6e1b8d60829 100644 --- a/homeassistant/components/bitvis/strings.json +++ b/homeassistant/components/bitvis/strings.json @@ -2,10 +2,12 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]" }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]" }, "step": { "user": { diff --git a/requirements_all.txt b/requirements_all.txt index 8977426f45b6b6..229c881de3fa0c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -654,7 +654,7 @@ batinfo==0.4.2 beautifulsoup4==4.13.3 # homeassistant.components.bitvis -bitvis-protobuf==0.2.7 +bitvis-protobuf==1.0.1 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/tests/components/bitvis/test_config_flow.py b/tests/components/bitvis/test_config_flow.py index 7d33133685c6c9..951230e19d49cf 100644 --- a/tests/components/bitvis/test_config_flow.py +++ b/tests/components/bitvis/test_config_flow.py @@ -4,9 +4,13 @@ from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock, patch +from bitvis_protobuf.parse import PayloadSample import pytest -from homeassistant.components.bitvis.config_flow import _async_test_port +from homeassistant.components.bitvis.config_flow import ( + _async_discover_mac_address, + _async_test_port, +) from homeassistant.components.bitvis.const import ( DEFAULT_NAME, DEFAULT_PORT, @@ -19,6 +23,8 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo +from .conftest import TEST_DEVICE_MAC + from tests.common import MockConfigEntry ZEROCONF_DISCOVERY = ZeroconfServiceInfo( @@ -52,6 +58,11 @@ async def test_user_form_create_entry(hass: HomeAssistant) -> None: "homeassistant.components.bitvis.config_flow._async_test_port", new_callable=AsyncMock, ), + patch( + "homeassistant.components.bitvis.config_flow._async_discover_mac_address", + new_callable=AsyncMock, + return_value=TEST_DEVICE_MAC, + ), patch( "homeassistant.components.bitvis.async_setup_entry", return_value=True, @@ -70,7 +81,7 @@ async def test_user_form_create_entry(hass: HomeAssistant) -> None: CONF_HOST: "192.168.1.100", CONF_PORT: DEFAULT_PORT, } - assert hass.config_entries.async_entries(DOMAIN)[0].unique_id == DOMAIN + assert hass.config_entries.async_entries(DOMAIN)[0].unique_id == TEST_DEVICE_MAC @pytest.mark.parametrize("recover", [False, True]) @@ -100,6 +111,11 @@ async def test_user_form_cannot_connect(hass: HomeAssistant, recover: bool) -> N "homeassistant.components.bitvis.config_flow._async_test_port", new_callable=AsyncMock, ), + patch( + "homeassistant.components.bitvis.config_flow._async_discover_mac_address", + new_callable=AsyncMock, + return_value=TEST_DEVICE_MAC, + ), patch( "homeassistant.components.bitvis.async_setup_entry", return_value=True, @@ -120,14 +136,42 @@ async def test_user_form_cannot_connect(hass: HomeAssistant, recover: bool) -> N } +async def test_user_form_discovery_timeout(hass: HomeAssistant) -> None: + """Test user form error when no UDP message is received in time.""" + 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.config_flow._async_discover_mac_address", + side_effect=TimeoutError, + ), + ): + 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": "timeout_connect"} + + async def test_user_form_duplicate(hass: HomeAssistant) -> None: - """Test duplicate detection.""" + """Test duplicate detection by MAC address.""" entry = MockConfigEntry( domain=DOMAIN, data={ CONF_HOST: "192.168.1.100", CONF_PORT: DEFAULT_PORT, }, + unique_id=TEST_DEVICE_MAC, ) entry.add_to_hass(hass) @@ -135,9 +179,16 @@ async def test_user_form_duplicate(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_USER} ) - with patch( - "homeassistant.components.bitvis.config_flow._async_test_port", - new_callable=AsyncMock, + with ( + patch( + "homeassistant.components.bitvis.config_flow._async_test_port", + new_callable=AsyncMock, + ), + patch( + "homeassistant.components.bitvis.config_flow._async_discover_mac_address", + new_callable=AsyncMock, + return_value=TEST_DEVICE_MAC, + ), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -165,6 +216,11 @@ async def test_zeroconf_confirm_creates_entry(hass: HomeAssistant) -> None: "homeassistant.components.bitvis.config_flow._async_test_port", new_callable=AsyncMock, ), + patch( + "homeassistant.components.bitvis.config_flow._async_discover_mac_address", + new_callable=AsyncMock, + return_value=TEST_DEVICE_MAC, + ), patch( "homeassistant.components.bitvis.async_setup_entry", return_value=True, @@ -179,7 +235,7 @@ async def test_zeroconf_confirm_creates_entry(hass: HomeAssistant) -> None: CONF_HOST: "192.168.1.200", CONF_PORT: DEFAULT_PORT, } - assert hass.config_entries.async_entries(DOMAIN)[0].unique_id == DOMAIN + assert hass.config_entries.async_entries(DOMAIN)[0].unique_id == TEST_DEVICE_MAC @pytest.mark.parametrize("recover", [False, True]) @@ -216,6 +272,11 @@ async def test_zeroconf_confirm_cannot_connect( "homeassistant.components.bitvis.config_flow._async_test_port", new_callable=AsyncMock, ), + patch( + "homeassistant.components.bitvis.config_flow._async_discover_mac_address", + new_callable=AsyncMock, + return_value=TEST_DEVICE_MAC, + ), patch( "homeassistant.components.bitvis.async_setup_entry", return_value=True, @@ -232,11 +293,38 @@ async def test_zeroconf_confirm_cannot_connect( } +async def test_zeroconf_confirm_discovery_timeout(hass: HomeAssistant) -> None: + """Test zeroconf confirm abort when no UDP message is received in time.""" + 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.config_flow._async_discover_mac_address", + side_effect=TimeoutError, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "timeout_connect" + + 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}, + unique_id=TEST_DEVICE_MAC, ) entry.add_to_hass(hass) @@ -273,6 +361,11 @@ async def test_zeroconf_none_port_uses_default(hass: HomeAssistant) -> None: "homeassistant.components.bitvis.config_flow._async_test_port", new_callable=AsyncMock, ), + patch( + "homeassistant.components.bitvis.config_flow._async_discover_mac_address", + new_callable=AsyncMock, + return_value=TEST_DEVICE_MAC, + ), patch( "homeassistant.components.bitvis.async_setup_entry", return_value=True, @@ -297,6 +390,11 @@ async def test_user_form_create_entry_ipv6_host(hass: HomeAssistant) -> None: "homeassistant.components.bitvis.config_flow._async_test_port", new_callable=AsyncMock, ), + patch( + "homeassistant.components.bitvis.config_flow._async_discover_mac_address", + new_callable=AsyncMock, + return_value=TEST_DEVICE_MAC, + ), patch( "homeassistant.components.bitvis.async_setup_entry", return_value=True, @@ -316,7 +414,7 @@ async def test_user_form_create_entry_ipv6_host(hass: HomeAssistant) -> None: CONF_PORT: DEFAULT_PORT, } assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert hass.config_entries.async_entries(DOMAIN)[0].unique_id == DOMAIN + assert hass.config_entries.async_entries(DOMAIN)[0].unique_id == TEST_DEVICE_MAC async def test_user_form_duplicate_host(hass: HomeAssistant) -> None: @@ -327,6 +425,7 @@ async def test_user_form_duplicate_host(hass: HomeAssistant) -> None: CONF_HOST: "2001:db8::10", CONF_PORT: DEFAULT_PORT, }, + unique_id="11:22:33:44:55:66", ) entry.add_to_hass(hass) @@ -334,9 +433,16 @@ async def test_user_form_duplicate_host(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_USER} ) - with patch( - "homeassistant.components.bitvis.config_flow._async_test_port", - new_callable=AsyncMock, + with ( + patch( + "homeassistant.components.bitvis.config_flow._async_test_port", + new_callable=AsyncMock, + ), + patch( + "homeassistant.components.bitvis.config_flow._async_discover_mac_address", + new_callable=AsyncMock, + return_value=TEST_DEVICE_MAC, + ), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -362,6 +468,11 @@ async def test_user_form_keeps_hostname( "homeassistant.components.bitvis.config_flow._async_test_port", new_callable=AsyncMock, ), + patch( + "homeassistant.components.bitvis.config_flow._async_discover_mac_address", + new_callable=AsyncMock, + return_value=TEST_DEVICE_MAC, + ), patch( "homeassistant.components.bitvis.async_setup_entry", return_value=True, @@ -392,6 +503,11 @@ async def test_user_form_normalize_bracketed_ipv6( "homeassistant.components.bitvis.config_flow._async_test_port", new_callable=AsyncMock, ), + patch( + "homeassistant.components.bitvis.config_flow._async_discover_mac_address", + new_callable=AsyncMock, + return_value=TEST_DEVICE_MAC, + ), patch( "homeassistant.components.bitvis.async_setup_entry", return_value=True, @@ -432,6 +548,11 @@ async def test_zeroconf_confirm_uses_friendly_name(hass: HomeAssistant) -> None: "homeassistant.components.bitvis.config_flow._async_test_port", new_callable=AsyncMock, ), + patch( + "homeassistant.components.bitvis.config_flow._async_discover_mac_address", + new_callable=AsyncMock, + return_value=TEST_DEVICE_MAC, + ), patch( "homeassistant.components.bitvis.async_setup_entry", return_value=True, @@ -468,6 +589,11 @@ async def test_zeroconf_empty_name_uses_default(hass: HomeAssistant) -> None: "homeassistant.components.bitvis.config_flow._async_test_port", new_callable=AsyncMock, ), + patch( + "homeassistant.components.bitvis.config_flow._async_discover_mac_address", + new_callable=AsyncMock, + return_value=TEST_DEVICE_MAC, + ), patch( "homeassistant.components.bitvis.async_setup_entry", return_value=True, @@ -534,3 +660,39 @@ async def test_async_test_port_raises_when_all_binds_fail( pytest.raises(OSError, match="UDP port is unavailable"), ): await _async_test_port(hass, 5000) + + +async def test_async_discover_mac_address(hass: HomeAssistant) -> None: + """Test _async_discover_mac_address registers IP filters and returns MAC.""" + mock_listener = MagicMock() + mock_listener.register = MagicMock() + mock_listener.unregister = MagicMock() + + with ( + patch( + "homeassistant.components.bitvis.config_flow.async_resolve_host", + new_callable=AsyncMock, + return_value={"192.168.1.100"}, + ), + patch( + "homeassistant.components.bitvis.config_flow.async_get_listener_registry", + ) as mock_registry, + ): + mock_registry.return_value.async_get_or_create = AsyncMock( + return_value=mock_listener + ) + + discover_task = asyncio.create_task( + _async_discover_mac_address(hass, "192.168.1.100", DEFAULT_PORT) + ) + await hass.async_block_till_done() + + callback = mock_listener.register.call_args[0][1] + + payload = PayloadSample(mac_address=TEST_DEVICE_MAC, sample=MagicMock()) + callback(payload, ("192.168.1.100", 1234)) + + mac_address = await discover_task + + assert mac_address == TEST_DEVICE_MAC + mock_listener.unregister.assert_called_once() diff --git a/tests/components/bitvis/test_coordinator.py b/tests/components/bitvis/test_coordinator.py index 9790a03bcfd15a..d9d7497ef1ed05 100644 --- a/tests/components/bitvis/test_coordinator.py +++ b/tests/components/bitvis/test_coordinator.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from bitvis_protobuf import powerhub_pb2 +from bitvis_protobuf.listener import FilterMac from bitvis_protobuf.parse import PayloadDiagnostic, PayloadSample import pytest @@ -38,7 +39,9 @@ def coordinator( ) -> BitvisDataUpdateCoordinator: """Return a coordinator instance (not started).""" config_entry.add_to_hass(hass) - return BitvisDataUpdateCoordinator(hass, config_entry, "192.168.1.100", 5000) + return BitvisDataUpdateCoordinator( + hass, config_entry, "192.168.1.100", 5000, TEST_DEVICE_MAC + ) # --------------------------------------------------------------------------- @@ -52,7 +55,7 @@ def test_handle_payload_sample( """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) + parsed = PayloadSample(mac_address=TEST_DEVICE_MAC, sample=payload.sample) with patch.object(coordinator, "_handle_sample") as mock_handle: coordinator._handle_payload(parsed, ("192.168.1.100", 1234)) @@ -66,7 +69,9 @@ def test_handle_payload_diagnostic( """Test that a PayloadDiagnostic is dispatched to _handle_diagnostic.""" payload = powerhub_pb2.Payload() payload.diagnostic.uptime_s = 42 - parsed = PayloadDiagnostic(diagnostic=payload.diagnostic) + parsed = PayloadDiagnostic( + mac_address=TEST_DEVICE_MAC, diagnostic=payload.diagnostic + ) with patch.object(coordinator, "_handle_diagnostic") as mock_handle: coordinator._handle_payload(parsed, ("192.168.1.100", 1234)) @@ -95,6 +100,9 @@ async def test_coordinator_async_setup_registers_callback( await coordinator._async_setup() mock_listener.register.assert_called_once() + registered_filter = mock_listener.register.call_args[0][0] + assert isinstance(registered_filter, FilterMac) + assert registered_filter.mac_address == TEST_DEVICE_MAC registered_callback = mock_listener.register.call_args[0][1] assert callable(registered_callback) @@ -112,7 +120,7 @@ async def test_coordinator_async_setup_reuses_existing_listener( ) config_entry2.add_to_hass(hass) coordinator2 = BitvisDataUpdateCoordinator( - hass, config_entry2, "192.168.1.101", 5000 + hass, config_entry2, "192.168.1.101", 5000, "11:22:33:44:55:66" ) mock_listener = MagicMock() @@ -172,7 +180,7 @@ async def test_coordinator_async_setup_runtime_error_raises_entry_error( mock_listener.start = AsyncMock() mock_listener.stop = AsyncMock() mock_listener.is_empty = True - mock_listener.register.side_effect = RuntimeError("duplicate IP") + mock_listener.register.side_effect = RuntimeError("duplicate filter") with ( patch( @@ -208,7 +216,7 @@ async def test_coordinator_handle_sample( """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) + parsed = PayloadSample(mac_address=TEST_DEVICE_MAC, sample=payload.sample) ha_listener = MagicMock() coordinator.async_add_listener(ha_listener) @@ -232,7 +240,9 @@ async def test_coordinator_handle_diagnostic( """Test that _handle_diagnostic updates coordinator data and notifies listeners.""" payload = powerhub_pb2.Payload() payload.diagnostic.uptime_s = 999 - parsed = PayloadDiagnostic(diagnostic=payload.diagnostic) + parsed = PayloadDiagnostic( + mac_address=TEST_DEVICE_MAC, diagnostic=payload.diagnostic + ) ha_listener = MagicMock() coordinator.async_add_listener(ha_listener) @@ -255,7 +265,11 @@ async def test_coordinator_handle_diagnostic_with_device_info( 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)) + coordinator._handle_diagnostic( + PayloadDiagnostic( + mac_address="aa:bb:cc:dd:ee:ff", diagnostic=payload.diagnostic + ) + ) await hass.async_block_till_done() assert coordinator.data.model_name == "PowerHub Gen2" @@ -272,14 +286,22 @@ async def test_coordinator_handle_diagnostic_clears_device_info( 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)) + coordinator._handle_diagnostic( + PayloadDiagnostic( + mac_address="aa:bb:cc:dd:ee:ff", 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)) + coordinator._handle_diagnostic( + PayloadDiagnostic( + mac_address="aa:bb:cc:dd:ee:ff", diagnostic=payload2.diagnostic + ) + ) - assert coordinator.data.mac_address is None + assert coordinator.data.mac_address == "aa:bb:cc:dd:ee:ff" assert coordinator.data.model_name is None assert coordinator.data.sw_version is None @@ -294,11 +316,13 @@ async def test_coordinator_async_stop_without_listener( ) -> 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) + coordinator = BitvisDataUpdateCoordinator( + hass, config_entry, "192.168.1.100", 5000, TEST_DEVICE_MAC + ) await coordinator.async_stop() - assert coordinator._registered_ips == set() + assert coordinator._filter.mac_address == TEST_DEVICE_MAC async def test_coordinator_async_stop_without_domain_data( @@ -306,9 +330,11 @@ async def test_coordinator_async_stop_without_domain_data( ) -> 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) + coordinator = BitvisDataUpdateCoordinator( + hass, config_entry, "192.168.1.100", 5000, TEST_DEVICE_MAC + ) hass.data.pop(DOMAIN, None) await coordinator.async_stop() - assert coordinator._registered_ips == set() + assert coordinator._filter.mac_address == TEST_DEVICE_MAC diff --git a/tests/components/bitvis/test_sensor.py b/tests/components/bitvis/test_sensor.py index 2f2eb949545144..e241df0468c86e 100644 --- a/tests/components/bitvis/test_sensor.py +++ b/tests/components/bitvis/test_sensor.py @@ -11,8 +11,17 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from .conftest import TEST_DEVICE_MAC + from tests.common import MockConfigEntry, snapshot_platform +DIAGNOSTIC_UNIQUE_IDS = { + f"{TEST_DEVICE_MAC}_uptime", + f"{TEST_DEVICE_MAC}_wifi_rssi", + f"{TEST_DEVICE_MAC}_han_msg_successfully_parsed", + f"{TEST_DEVICE_MAC}_han_msg_buffer_overflow", +} + @pytest.fixture(autouse=True) def enable_all_entities(entity_registry_enabled_by_default: None) -> None: @@ -49,7 +58,8 @@ def sample_payload() -> PayloadSample: 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) + payload.mac_address = b"\xaa\xbb\xcc\xdd\xee\xff" + return PayloadSample(mac_address=TEST_DEVICE_MAC, sample=payload.sample) @pytest.fixture @@ -63,7 +73,8 @@ def diagnostic_payload() -> PayloadDiagnostic: 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) + payload.mac_address = b"\xaa\xbb\xcc\xdd\xee\xff" + return PayloadDiagnostic(mac_address=TEST_DEVICE_MAC, diagnostic=payload.diagnostic) @pytest.mark.freeze_time("2026-01-01 12:00:00") @@ -98,19 +109,63 @@ async def test_all_entities( @pytest.mark.usefixtures("init_integration") -async def test_sensors_unavailable_without_data( +async def test_diagnostic_entities_created_at_setup( + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that diagnostic sensors are created at setup.""" + unique_ids = { + entry.unique_id + for entry in er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + } + assert unique_ids == DIAGNOSTIC_UNIQUE_IDS + + +@pytest.mark.usefixtures("init_integration") +async def test_entities_added_when_fields_become_available( 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 + """Test that HAN sensors are created when their fields first appear.""" + coordinator = mock_config_entry.runtime_data + base_unique_id = mock_config_entry.unique_id + + payload = powerhub_pb2.Payload() + payload.sample.power_active_delivered_to_client_kw = 2.0 + coordinator._handle_sample( + PayloadSample(mac_address=TEST_DEVICE_MAC, sample=payload.sample) ) - 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" + await hass.async_block_till_done() + + unique_ids = { + entry.unique_id + for entry in er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + } + assert unique_ids == DIAGNOSTIC_UNIQUE_IDS | { + f"{base_unique_id}_power_active_delivered_to_client" + } + + payload.sample.phase_voltage_l1_v = 230.0 + coordinator._handle_sample( + PayloadSample(mac_address=TEST_DEVICE_MAC, sample=payload.sample) + ) + await hass.async_block_till_done() + + unique_ids = { + entry.unique_id + for entry in er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + } + assert unique_ids == DIAGNOSTIC_UNIQUE_IDS | { + f"{base_unique_id}_power_active_delivered_to_client", + f"{base_unique_id}_phase_voltage_l1", + } @pytest.mark.usefixtures("init_integration") @@ -124,7 +179,10 @@ async def test_sensors_become_available_with_data( payload = powerhub_pb2.Payload() payload.sample.power_active_delivered_to_client_kw = 2.0 - coordinator._handle_sample(PayloadSample(sample=payload.sample)) + payload.mac_address = b"\xaa\xbb\xcc\xdd\xee\xff" + coordinator._handle_sample( + PayloadSample(mac_address=TEST_DEVICE_MAC, sample=payload.sample) + ) await hass.async_block_till_done() base_unique_id = mock_config_entry.unique_id