Add support for externally connected utility devices in HomeWizard (#100684)

* Backport code from #86386

* Add tests

* Remove local dev change

* Implement device class validation based on unit

* Swap sensor and externalsensor classes (based on importance)

* Use translations for external sensor entities

* Re-add meter identifier as sensor for external devices

* Add migration for Gas identifier

* Rename HomeWizardExternalIdentifierSensorEntity class

* Fix all existing tests

* Reimplement tests for extenal devices with smapshots

* Remove non-used 'None' type in unit

* Add migration test

* Clean up parameterize

* Add test to fix last coverage issue

* Fix non-frozen mypy issue

* Set device name via added EntityDescription field

* Remove device key translations for external sensors,

* Bring back translation keys

* Set device unique_id as serial number

* Remove meter identifier sensor

* Simplify external device initialization

* Adjust tests

* Remove unused gas_meter_id migration

* Remove external_devices redaction

* Remove old gas meter id sensor after migration
This commit is contained in:
Duco Sebel 2024-01-25 12:51:50 +01:00 committed by GitHub
parent c54b65fdf0
commit 0c9a30ab69
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 2165 additions and 37 deletions

View file

@ -5,9 +5,10 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import Final
from homewizard_energy.models import Data
from homewizard_energy.models import Data, ExternalDevice
from homeassistant.components.sensor import (
DEVICE_CLASS_UNITS,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
@ -15,8 +16,10 @@ from homeassistant.components.sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_VIA_DEVICE,
PERCENTAGE,
EntityCategory,
Platform,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
@ -25,6 +28,8 @@ from homeassistant.const import (
UnitOfVolume,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
@ -44,6 +49,14 @@ class HomeWizardSensorEntityDescription(SensorEntityDescription):
value_fn: Callable[[Data], StateType]
@dataclass(frozen=True, kw_only=True)
class HomeWizardExternalSensorEntityDescription(SensorEntityDescription):
"""Class describing HomeWizard sensor entities."""
suggested_device_class: SensorDeviceClass
device_name: str
SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
HomeWizardSensorEntityDescription(
key="smr_version",
@ -378,22 +391,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
has_fn=lambda data: data.monthly_power_peak_w is not None,
value_fn=lambda data: data.monthly_power_peak_w,
),
HomeWizardSensorEntityDescription(
key="total_gas_m3",
translation_key="total_gas_m3",
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
device_class=SensorDeviceClass.GAS,
state_class=SensorStateClass.TOTAL_INCREASING,
has_fn=lambda data: data.total_gas_m3 is not None,
value_fn=lambda data: data.total_gas_m3,
),
HomeWizardSensorEntityDescription(
key="gas_unique_id",
translation_key="gas_unique_id",
entity_category=EntityCategory.DIAGNOSTIC,
has_fn=lambda data: data.gas_unique_id is not None,
value_fn=lambda data: data.gas_unique_id,
),
HomeWizardSensorEntityDescription(
key="active_liter_lpm",
translation_key="active_liter_lpm",
@ -414,16 +411,86 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
)
EXTERNAL_SENSORS = {
ExternalDevice.DeviceType.GAS_METER: HomeWizardExternalSensorEntityDescription(
key="gas_meter",
translation_key="total_gas_m3",
suggested_device_class=SensorDeviceClass.GAS,
state_class=SensorStateClass.TOTAL_INCREASING,
device_name="Gas meter",
),
ExternalDevice.DeviceType.HEAT_METER: HomeWizardExternalSensorEntityDescription(
key="heat_meter",
translation_key="total_energy_gj",
suggested_device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
device_name="Heat meter",
),
ExternalDevice.DeviceType.WARM_WATER_METER: HomeWizardExternalSensorEntityDescription(
key="warm_water_meter",
translation_key="total_liter_m3",
suggested_device_class=SensorDeviceClass.WATER,
state_class=SensorStateClass.TOTAL_INCREASING,
device_name="Warm water meter",
),
ExternalDevice.DeviceType.WATER_METER: HomeWizardExternalSensorEntityDescription(
key="water_meter",
translation_key="total_liter_m3",
suggested_device_class=SensorDeviceClass.WATER,
state_class=SensorStateClass.TOTAL_INCREASING,
device_name="Water meter",
),
ExternalDevice.DeviceType.INLET_HEAT_METER: HomeWizardExternalSensorEntityDescription(
key="inlet_heat_meter",
translation_key="total_energy_gj",
suggested_device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
device_name="Inlet heat meter",
),
}
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Initialize sensors."""
coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
# Migrate original gas meter sensor to ExternalDevice
ent_reg = er.async_get(hass)
if (
entity_id := ent_reg.async_get_entity_id(
Platform.SENSOR, DOMAIN, f"{entry.unique_id}_total_gas_m3"
)
) and coordinator.data.data.gas_unique_id is not None:
ent_reg.async_update_entity(
entity_id,
new_unique_id=f"{DOMAIN}_{coordinator.data.data.gas_unique_id}",
)
# Remove old gas_unique_id sensor
if entity_id := ent_reg.async_get_entity_id(
Platform.SENSOR, DOMAIN, f"{entry.unique_id}_gas_unique_id"
):
ent_reg.async_remove(entity_id)
# Initialize default sensors
entities: list = [
HomeWizardSensorEntity(coordinator, description)
for description in SENSORS
if description.has_fn(coordinator.data.data)
)
]
# Initialize external devices
if coordinator.data.data.external_devices is not None:
for unique_id, device in coordinator.data.data.external_devices.items():
if description := EXTERNAL_SENSORS.get(device.meter_type):
entities.append(
HomeWizardExternalSensorEntity(coordinator, description, unique_id)
)
async_add_entities(entities)
class HomeWizardSensorEntity(HomeWizardEntity, SensorEntity):
@ -452,3 +519,74 @@ class HomeWizardSensorEntity(HomeWizardEntity, SensorEntity):
def available(self) -> bool:
"""Return availability of meter."""
return super().available and self.native_value is not None
class HomeWizardExternalSensorEntity(HomeWizardEntity, SensorEntity):
"""Representation of externally connected HomeWizard Sensor."""
def __init__(
self,
coordinator: HWEnergyDeviceUpdateCoordinator,
description: HomeWizardExternalSensorEntityDescription,
device_unique_id: str,
) -> None:
"""Initialize Externally connected HomeWizard Sensors."""
super().__init__(coordinator)
self.entity_description = description
self._device_id = device_unique_id
self._suggested_device_class = description.suggested_device_class
self._attr_unique_id = f"{DOMAIN}_{device_unique_id}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device_unique_id)},
name=description.device_name,
manufacturer="HomeWizard",
model=coordinator.data.device.product_type,
serial_number=device_unique_id,
)
if coordinator.data.device.serial is not None:
self._attr_device_info[ATTR_VIA_DEVICE] = (
DOMAIN,
coordinator.data.device.serial,
)
@property
def native_value(self) -> float | int | str | None:
"""Return the sensor value."""
return self.device.value if self.device is not None else None
@property
def device(self) -> ExternalDevice | None:
"""Return ExternalDevice object."""
return (
self.coordinator.data.data.external_devices[self._device_id]
if self.coordinator.data.data.external_devices is not None
else None
)
@property
def available(self) -> bool:
"""Return availability of meter."""
return super().available and self.device is not None
@property
def native_unit_of_measurement(self) -> str | None:
"""Return unit of measurement based on device unit."""
if (device := self.device) is None:
return None
# API returns 'm3' but we expect m³
if device.unit == "m3":
return UnitOfVolume.CUBIC_METERS
return device.unit
@property
def device_class(self) -> SensorDeviceClass | None:
"""Validate unit of measurement and set device class."""
if (
self.native_unit_of_measurement
not in DEVICE_CLASS_UNITS[self._suggested_device_class]
):
return None
return self._suggested_device_class

View file

@ -149,14 +149,17 @@
"total_gas_m3": {
"name": "Total gas"
},
"gas_unique_id": {
"name": "Gas meter identifier"
"meter_identifier": {
"name": "Meter identifier"
},
"active_liter_lpm": {
"name": "Active water usage"
},
"total_liter_m3": {
"name": "Total water usage"
},
"total_energy_gj": {
"name": "Total heat energy"
}
},
"switch": {

View file

@ -41,5 +41,42 @@
"montly_power_peak_w": 1111.0,
"montly_power_peak_timestamp": 230101080010,
"active_liter_lpm": 12.345,
"total_liter_m3": 1234.567
"total_liter_m3": 1234.567,
"external": [
{
"unique_id": "47303031",
"type": "gas_meter",
"timestamp": 230125220957,
"value": 111.111,
"unit": "m3"
},
{
"unique_id": "57303031",
"type": "water_meter",
"timestamp": 230125220957,
"value": 222.222,
"unit": "m3"
},
{
"unique_id": "5757303031",
"type": "warm_water_meter",
"timestamp": 230125220957,
"value": 333.333,
"unit": "m3"
},
{
"unique_id": "48303031",
"type": "heat_meter",
"timestamp": 230125220957,
"value": 444.444,
"unit": "GJ"
},
{
"unique_id": "4948303031",
"type": "inlet_heat_meter",
"timestamp": 230125220957,
"value": 555.555,
"unit": "m3"
}
]
}

View file

@ -32,7 +32,58 @@
'active_voltage_l3_v': 230.333,
'active_voltage_v': None,
'any_power_fail_count': 4,
'external_devices': None,
'external_devices': dict({
'G001': dict({
'meter_type': dict({
'__type': "<enum 'DeviceType'>",
'repr': '<DeviceType.GAS_METER: 3>',
}),
'timestamp': '2023-01-25T22:09:57',
'unique_id': '**REDACTED**',
'unit': 'm3',
'value': 111.111,
}),
'H001': dict({
'meter_type': dict({
'__type': "<enum 'DeviceType'>",
'repr': '<DeviceType.HEAT_METER: 4>',
}),
'timestamp': '2023-01-25T22:09:57',
'unique_id': '**REDACTED**',
'unit': 'GJ',
'value': 444.444,
}),
'IH001': dict({
'meter_type': dict({
'__type': "<enum 'DeviceType'>",
'repr': '<DeviceType.INLET_HEAT_METER: 12>',
}),
'timestamp': '2023-01-25T22:09:57',
'unique_id': '**REDACTED**',
'unit': 'm3',
'value': 555.555,
}),
'W001': dict({
'meter_type': dict({
'__type': "<enum 'DeviceType'>",
'repr': '<DeviceType.WATER_METER: 7>',
}),
'timestamp': '2023-01-25T22:09:57',
'unique_id': '**REDACTED**',
'unit': 'm3',
'value': 222.222,
}),
'WW001': dict({
'meter_type': dict({
'__type': "<enum 'DeviceType'>",
'repr': '<DeviceType.WARM_WATER_METER: 6>',
}),
'timestamp': '2023-01-25T22:09:57',
'unique_id': '**REDACTED**',
'unit': 'm3',
'value': 333.333,
}),
}),
'gas_timestamp': '2021-03-14T11:22:33',
'gas_unique_id': '**REDACTED**',
'long_power_fail_count': 5,

File diff suppressed because it is too large Load diff

View file

@ -3,16 +3,18 @@
from unittest.mock import MagicMock
from homewizard_energy.errors import DisabledError, RequestError
from homewizard_energy.models import Data
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.homewizard import DOMAIN
from homeassistant.components.homewizard.const import UPDATE_INTERVAL
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
import homeassistant.util.dt as dt_util
from tests.common import async_fire_time_changed
from tests.common import MockConfigEntry, async_fire_time_changed
pytestmark = [
pytest.mark.usefixtures("init_integration"),
@ -63,10 +65,13 @@ pytestmark = [
"sensor.device_long_power_failures_detected",
"sensor.device_active_average_demand",
"sensor.device_peak_demand_current_month",
"sensor.device_total_gas",
"sensor.device_gas_meter_identifier",
"sensor.device_active_water_usage",
"sensor.device_total_water_usage",
"sensor.gas_meter_total_gas",
"sensor.water_meter_total_water_usage",
"sensor.warm_water_meter_total_water_usage",
"sensor.heat_meter_total_heat_energy",
"sensor.inlet_heat_meter_total_heat_energy",
],
),
(
@ -102,8 +107,6 @@ pytestmark = [
"sensor.device_power_failures_detected",
"sensor.device_long_power_failures_detected",
"sensor.device_active_average_demand",
"sensor.device_peak_demand_current_month",
"sensor.device_total_gas",
"sensor.device_active_water_usage",
"sensor.device_total_water_usage",
],
@ -256,6 +259,22 @@ async def test_sensors_unreachable(
assert state.state == STATE_UNAVAILABLE
async def test_external_sensors_unreachable(
hass: HomeAssistant,
mock_homewizardenergy: MagicMock,
) -> None:
"""Test external device sensor handles API unreachable."""
assert (state := hass.states.get("sensor.gas_meter_total_gas"))
assert state.state == "111.111"
mock_homewizardenergy.data.return_value = Data.from_dict({})
async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL)
await hass.async_block_till_done()
assert (state := hass.states.get(state.entity_id))
assert state.state == STATE_UNAVAILABLE
@pytest.mark.parametrize(
("device_fixture", "entity_ids"),
[
@ -275,7 +294,6 @@ async def test_sensors_unreachable(
"sensor.device_active_voltage_phase_3",
"sensor.device_active_water_usage",
"sensor.device_dsmr_version",
"sensor.device_gas_meter_identifier",
"sensor.device_long_power_failures_detected",
"sensor.device_peak_demand_current_month",
"sensor.device_power_failures_detected",
@ -289,7 +307,6 @@ async def test_sensors_unreachable(
"sensor.device_total_energy_import_tariff_2",
"sensor.device_total_energy_import_tariff_3",
"sensor.device_total_energy_import_tariff_4",
"sensor.device_total_gas",
"sensor.device_total_water_usage",
"sensor.device_voltage_sags_detected_phase_1",
"sensor.device_voltage_sags_detected_phase_2",
@ -337,8 +354,6 @@ async def test_sensors_unreachable(
"sensor.device_long_power_failures_detected",
"sensor.device_active_average_demand",
"sensor.device_peak_demand_current_month",
"sensor.device_total_gas",
"sensor.device_gas_meter_identifier",
],
),
(
@ -357,7 +372,6 @@ async def test_sensors_unreachable(
"sensor.device_active_voltage_phase_3",
"sensor.device_active_water_usage",
"sensor.device_dsmr_version",
"sensor.device_gas_meter_identifier",
"sensor.device_long_power_failures_detected",
"sensor.device_peak_demand_current_month",
"sensor.device_power_failures_detected",
@ -371,7 +385,6 @@ async def test_sensors_unreachable(
"sensor.device_total_energy_import_tariff_2",
"sensor.device_total_energy_import_tariff_3",
"sensor.device_total_energy_import_tariff_4",
"sensor.device_total_gas",
"sensor.device_total_water_usage",
"sensor.device_voltage_sags_detected_phase_1",
"sensor.device_voltage_sags_detected_phase_2",
@ -395,7 +408,6 @@ async def test_sensors_unreachable(
"sensor.device_active_voltage_phase_3",
"sensor.device_active_water_usage",
"sensor.device_dsmr_version",
"sensor.device_gas_meter_identifier",
"sensor.device_long_power_failures_detected",
"sensor.device_peak_demand_current_month",
"sensor.device_power_failures_detected",
@ -409,7 +421,6 @@ async def test_sensors_unreachable(
"sensor.device_total_energy_import_tariff_2",
"sensor.device_total_energy_import_tariff_3",
"sensor.device_total_energy_import_tariff_4",
"sensor.device_total_gas",
"sensor.device_total_water_usage",
"sensor.device_voltage_sags_detected_phase_1",
"sensor.device_voltage_sags_detected_phase_2",
@ -428,3 +439,49 @@ async def test_entities_not_created_for_device(
"""Ensures entities for a specific device are not created."""
for entity_id in entity_ids:
assert not hass.states.get(entity_id)
async def test_gas_meter_migrated(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
init_integration: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test old gas meter sensor is migrated."""
entity_registry.async_get_or_create(
Platform.SENSOR,
DOMAIN,
"aabbccddeeff_total_gas_m3",
)
await hass.config_entries.async_reload(init_integration.entry_id)
await hass.async_block_till_done()
entity_id = "sensor.homewizard_aabbccddeeff_total_gas_m3"
assert (entity_entry := entity_registry.async_get(entity_id))
assert snapshot(name=f"{entity_id}:entity-registry") == entity_entry
# Make really sure this happens
assert entity_entry.previous_unique_id == "aabbccddeeff_total_gas_m3"
async def test_gas_unique_id_removed(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
init_integration: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test old gas meter id sensor is removed."""
entity_registry.async_get_or_create(
Platform.SENSOR,
DOMAIN,
"aabbccddeeff_gas_unique_id",
)
await hass.config_entries.async_reload(init_integration.entry_id)
await hass.async_block_till_done()
entity_id = "sensor.homewizard_aabbccddeeff_gas_unique_id"
assert not entity_registry.async_get(entity_id)