Files

502 lines
20 KiB
Python

"""Sensor platform for Bitvis Power Hub."""
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING, cast, override
from bitvis_protobuf.han_port_pb2 import HanPortSample
from bitvis_protobuf.powerhub_pb2 import Diagnostic
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfPower,
UnitOfReactiveEnergy,
UnitOfReactivePower,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import BitvisConfigEntry
from .const import DOMAIN, MANUFACTURER
from .coordinator import BitvisDataUpdateCoordinator
PARALLEL_UPDATES = 0
def _optional(field: str) -> Callable[[HanPortSample], float | None]:
"""Return a getter that yields None when a protobuf field is unset."""
def _get(data: HanPortSample) -> float | None:
if data.HasField(field):
return cast(float, getattr(data, field))
return None
return _get
@dataclass(frozen=True, kw_only=True)
class BitvisSensorEntityDescription(SensorEntityDescription):
"""Describes Bitvis sensor entity."""
value_fn: Callable[[HanPortSample], float | None]
@dataclass(frozen=True, kw_only=True)
class BitvisDiagnosticSensorEntityDescription(SensorEntityDescription):
"""Describes Bitvis diagnostic sensor entity."""
value_fn: Callable[[Diagnostic], float | int | str | datetime | None]
SENSOR_DESCRIPTIONS: tuple[BitvisSensorEntityDescription, ...] = (
# Phase voltages
BitvisSensorEntityDescription(
key="phase_voltage_l1",
translation_key="phase_voltage",
translation_placeholders={"phase": "L1"},
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=_optional("phase_voltage_l1_v"),
),
BitvisSensorEntityDescription(
key="phase_voltage_l2",
translation_key="phase_voltage",
translation_placeholders={"phase": "L2"},
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=_optional("phase_voltage_l2_v"),
),
BitvisSensorEntityDescription(
key="phase_voltage_l3",
translation_key="phase_voltage",
translation_placeholders={"phase": "L3"},
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=_optional("phase_voltage_l3_v"),
),
# Phase currents
BitvisSensorEntityDescription(
key="phase_current_l1",
translation_key="phase_current",
translation_placeholders={"phase": "L1"},
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=2,
value_fn=_optional("phase_current_l1_a"),
),
BitvisSensorEntityDescription(
key="phase_current_l2",
translation_key="phase_current",
translation_placeholders={"phase": "L2"},
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=2,
value_fn=_optional("phase_current_l2_a"),
),
BitvisSensorEntityDescription(
key="phase_current_l3",
translation_key="phase_current",
translation_placeholders={"phase": "L3"},
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=2,
value_fn=_optional("phase_current_l3_a"),
),
# Total active power
BitvisSensorEntityDescription(
key="power_active_delivered_to_client",
translation_key="power_active_import",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.KILO_WATT,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=3,
value_fn=_optional("power_active_delivered_to_client_kw"),
),
BitvisSensorEntityDescription(
key="power_active_delivered_by_client",
translation_key="power_active_export",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.KILO_WATT,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=3,
value_fn=_optional("power_active_delivered_by_client_kw"),
),
# Total reactive power
BitvisSensorEntityDescription(
key="power_reactive_delivered_to_client",
translation_key="power_reactive_import",
device_class=SensorDeviceClass.REACTIVE_POWER,
native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=_optional("power_reactive_delivered_to_client_kvar"),
),
BitvisSensorEntityDescription(
key="power_reactive_delivered_by_client",
translation_key="power_reactive_export",
device_class=SensorDeviceClass.REACTIVE_POWER,
native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=_optional("power_reactive_delivered_by_client_kvar"),
),
# Per-phase active power (to client)
BitvisSensorEntityDescription(
key="power_active_l1_delivered_to_client",
translation_key="power_active_phase_import",
translation_placeholders={"phase": "L1"},
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.KILO_WATT,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=_optional("power_active_l1_delivered_to_client_kw"),
),
BitvisSensorEntityDescription(
key="power_active_l2_delivered_to_client",
translation_key="power_active_phase_import",
translation_placeholders={"phase": "L2"},
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.KILO_WATT,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=_optional("power_active_l2_delivered_to_client_kw"),
),
BitvisSensorEntityDescription(
key="power_active_l3_delivered_to_client",
translation_key="power_active_phase_import",
translation_placeholders={"phase": "L3"},
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.KILO_WATT,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=_optional("power_active_l3_delivered_to_client_kw"),
),
# Per-phase active power (by client)
BitvisSensorEntityDescription(
key="power_active_l1_delivered_by_client",
translation_key="power_active_phase_export",
translation_placeholders={"phase": "L1"},
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.KILO_WATT,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=_optional("power_active_l1_delivered_by_client_kw"),
),
BitvisSensorEntityDescription(
key="power_active_l2_delivered_by_client",
translation_key="power_active_phase_export",
translation_placeholders={"phase": "L2"},
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.KILO_WATT,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=_optional("power_active_l2_delivered_by_client_kw"),
),
BitvisSensorEntityDescription(
key="power_active_l3_delivered_by_client",
translation_key="power_active_phase_export",
translation_placeholders={"phase": "L3"},
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.KILO_WATT,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=_optional("power_active_l3_delivered_by_client_kw"),
),
# Per-phase reactive power (to client)
BitvisSensorEntityDescription(
key="power_reactive_l1_delivered_to_client",
translation_key="power_reactive_phase_import",
translation_placeholders={"phase": "L1"},
device_class=SensorDeviceClass.REACTIVE_POWER,
native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=_optional("power_reactive_l1_delivered_to_client_kvar"),
),
BitvisSensorEntityDescription(
key="power_reactive_l2_delivered_to_client",
translation_key="power_reactive_phase_import",
translation_placeholders={"phase": "L2"},
device_class=SensorDeviceClass.REACTIVE_POWER,
native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=_optional("power_reactive_l2_delivered_to_client_kvar"),
),
BitvisSensorEntityDescription(
key="power_reactive_l3_delivered_to_client",
translation_key="power_reactive_phase_import",
translation_placeholders={"phase": "L3"},
device_class=SensorDeviceClass.REACTIVE_POWER,
native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=_optional("power_reactive_l3_delivered_to_client_kvar"),
),
# Per-phase reactive power (by client)
BitvisSensorEntityDescription(
key="power_reactive_l1_delivered_by_client",
translation_key="power_reactive_phase_export",
translation_placeholders={"phase": "L1"},
device_class=SensorDeviceClass.REACTIVE_POWER,
native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=_optional("power_reactive_l1_delivered_by_client_kvar"),
),
BitvisSensorEntityDescription(
key="power_reactive_l2_delivered_by_client",
translation_key="power_reactive_phase_export",
translation_placeholders={"phase": "L2"},
device_class=SensorDeviceClass.REACTIVE_POWER,
native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=_optional("power_reactive_l2_delivered_by_client_kvar"),
),
BitvisSensorEntityDescription(
key="power_reactive_l3_delivered_by_client",
translation_key="power_reactive_phase_export",
translation_placeholders={"phase": "L3"},
device_class=SensorDeviceClass.REACTIVE_POWER,
native_unit_of_measurement=UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=_optional("power_reactive_l3_delivered_by_client_kvar"),
),
# Energy - active
BitvisSensorEntityDescription(
key="energy_active_delivered_to_client",
translation_key="energy_active_import",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2,
value_fn=_optional("energy_active_delivered_to_client_kwh"),
),
BitvisSensorEntityDescription(
key="energy_active_delivered_by_client",
translation_key="energy_active_export",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2,
value_fn=_optional("energy_active_delivered_by_client_kwh"),
),
# Energy - reactive
BitvisSensorEntityDescription(
key="energy_reactive_delivered_to_client",
translation_key="energy_reactive_import",
device_class=SensorDeviceClass.REACTIVE_ENERGY,
native_unit_of_measurement=UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2,
entity_registry_enabled_default=False,
value_fn=_optional("energy_reactive_delivered_to_client_kvarh"),
),
BitvisSensorEntityDescription(
key="energy_reactive_delivered_by_client",
translation_key="energy_reactive_export",
device_class=SensorDeviceClass.REACTIVE_ENERGY,
native_unit_of_measurement=UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2,
entity_registry_enabled_default=False,
value_fn=_optional("energy_reactive_delivered_by_client_kvarh"),
),
)
UPTIME_DESCRIPTION = SensorEntityDescription(
key="uptime",
device_class=SensorDeviceClass.UPTIME,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
)
DIAGNOSTIC_SENSOR_DESCRIPTIONS: tuple[BitvisDiagnosticSensorEntityDescription, ...] = (
BitvisDiagnosticSensorEntityDescription(
key="wifi_rssi",
translation_key="wifi_rssi",
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: data.wifi_rssi_dbm,
),
BitvisDiagnosticSensorEntityDescription(
key="han_msg_successfully_parsed",
translation_key="han_msg_successfully_parsed",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: data.han_msg_successfully_parsed,
),
BitvisDiagnosticSensorEntityDescription(
key="han_msg_buffer_overflow",
translation_key="han_msg_buffer_overflow",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: data.han_msg_buffer_overflow,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: BitvisConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Bitvis sensor platform."""
coordinator = entry.runtime_data
known_keys: set[str] = set()
async_add_entities(
[
BitvisUptimeSensorEntity(coordinator, UPTIME_DESCRIPTION),
*(
BitvisDiagnosticSensorEntity(coordinator, description)
for description in DIAGNOSTIC_SENSOR_DESCRIPTIONS
),
]
)
@callback
def _check_entities() -> None:
if (payload := coordinator.data.sample) is None:
return
entities = [
BitvisSensorEntity(coordinator, description)
for description in SENSOR_DESCRIPTIONS
if description.key not in known_keys
and description.value_fn(payload.sample) is not None
]
if entities:
known_keys.update(entity.entity_description.key for entity in entities)
async_add_entities(entities)
_check_entities()
entry.async_on_unload(coordinator.async_add_listener(_check_entities))
class BitvisBaseSensorEntity(
CoordinatorEntity[BitvisDataUpdateCoordinator], SensorEntity
):
"""Base class for Bitvis sensor entities."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: BitvisDataUpdateCoordinator,
description: SensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
mac_address = coordinator.mac_address
self._attr_unique_id = f"{mac_address}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, mac_address)},
connections={(CONNECTION_NETWORK_MAC, mac_address)},
manufacturer=MANUFACTURER,
)
class BitvisSensorEntity(BitvisBaseSensorEntity):
"""Representation of a Bitvis sensor."""
entity_description: BitvisSensorEntityDescription
@property
@override
def native_value(self) -> float | None:
"""Return the state of the sensor."""
payload = self.coordinator.data.sample
if TYPE_CHECKING:
assert payload is not None
return self.entity_description.value_fn(payload.sample)
@property
@override
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.coordinator.data.sample is not None
class BitvisDiagnosticSensorEntity(BitvisBaseSensorEntity):
"""Representation of a Bitvis diagnostic sensor."""
entity_description: BitvisDiagnosticSensorEntityDescription
@property
@override
def native_value(self) -> float | int | str | datetime | None:
"""Return the state of the sensor."""
payload = self.coordinator.data.diagnostic
if TYPE_CHECKING:
assert payload is not None
return self.entity_description.value_fn(payload.diagnostic)
@property
@override
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.coordinator.data.diagnostic is not None
class BitvisUptimeSensorEntity(BitvisBaseSensorEntity):
"""Sensor entity for device uptime (boot time)."""
@property
@override
def native_value(self) -> datetime | None:
"""Return the stable boot time computed by the coordinator."""
return self.coordinator.data.boot_time
@property
@override
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.coordinator.data.boot_time is not None