Add diagnostic sensors to WattWächter Plus (#177150)

This commit is contained in:
smartcircuits
2026-08-25 19:43:29 +02:00
committed by GitHub
parent 2728ba35b9
commit 80dcb49b6c
11 changed files with 307 additions and 28 deletions
@@ -1,5 +1,6 @@
"""DataUpdateCoordinator for the WattWächter Plus integration."""
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import override
@@ -8,9 +9,10 @@ from aio_wattwaechter import (
Wattwaechter,
WattwaechterAuthenticationError,
WattwaechterConnectionError,
WattwaechterError,
WattwaechterNoDataError,
)
from aio_wattwaechter.models import MeterData
from aio_wattwaechter.models import MeterData, SystemInfo
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_MAC, CONF_MODEL
@@ -25,7 +27,15 @@ _LOGGER = logging.getLogger(__name__)
type WattwaechterConfigEntry = ConfigEntry[WattwaechterCoordinator]
class WattwaechterCoordinator(DataUpdateCoordinator[MeterData]):
@dataclass
class WattwaechterData:
"""Data returned by a single WattWächter poll."""
meter: MeterData
system: SystemInfo | None
class WattwaechterCoordinator(DataUpdateCoordinator[WattwaechterData]):
"""Coordinator for WattWächter Plus data updates."""
config_entry: WattwaechterConfigEntry
@@ -53,7 +63,7 @@ class WattwaechterCoordinator(DataUpdateCoordinator[MeterData]):
)
@override
async def _async_update_data(self) -> MeterData:
async def _async_update_data(self) -> WattwaechterData:
"""Fetch data from the WattWächter device."""
try:
data = await self.client.meter_data()
@@ -83,4 +93,13 @@ class WattwaechterCoordinator(DataUpdateCoordinator[MeterData]):
translation_placeholders={"host": self.host},
)
return data
# System info is fetched best-effort: a failure here must not take the
# meter sensors unavailable, so the diagnostic sensors just report
# unknown until the next successful poll.
system: SystemInfo | None
try:
system = await self.client.system_info()
except WattwaechterError:
system = None
return WattwaechterData(meter=data, system=system)
@@ -3,10 +3,6 @@
from dataclasses import asdict
from typing import Any
from aio_wattwaechter import (
WattwaechterAuthenticationError,
WattwaechterConnectionError,
)
from aio_wattwaechter.models import SystemInfo
from homeassistant.components.diagnostics import async_redact_data
@@ -32,22 +28,13 @@ async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: WattwaechterConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
# System info is only needed on demand here, so it is fetched directly
# instead of in the update loop to avoid coupling meter sensor
# availability to it. Failure still yields the config and meter data.
system: dict[str, dict[str, Any]] | None = None
try:
system = _flatten_system(await coordinator.client.system_info())
except WattwaechterConnectionError, WattwaechterAuthenticationError:
system = None
data = entry.runtime_data.data
return async_redact_data(
{
"config_entry": dict(entry.data),
"meter": asdict(coordinator.data),
"system": system,
"meter": asdict(data.meter),
"system": _flatten_system(data.system) if data.system else None,
},
TO_REDACT,
)
@@ -15,11 +15,15 @@ class WattwaechterEntity(CoordinatorEntity[WattwaechterCoordinator]):
def __init__(self, coordinator: WattwaechterCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
# Prefer the device's mDNS hostname for the visit link (stable across
# DHCP changes); fall back to the host/IP when system info is missing.
system = coordinator.data.system
mdns_name = system.get_value("wifi", "mdns_name") if system else None
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.device_id)},
connections={(CONNECTION_NETWORK_MAC, coordinator.mac)},
manufacturer=MANUFACTURER,
model=coordinator.model,
sw_version=coordinator.fw_version,
configuration_url=f"http://{coordinator.host}",
configuration_url=f"http://{mdns_name or coordinator.host}",
)
@@ -18,6 +18,9 @@
},
"import_total": {
"default": "mdi:meter-electric"
},
"ssid": {
"default": "mdi:access-point"
}
}
}
@@ -52,11 +52,11 @@ rules:
diagnostics: done
discovery-update-info: done
discovery: done
docs-data-update: todo
docs-data-update: done
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
@@ -1,5 +1,7 @@
"""Sensor platform for the WattWächter Plus integration."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import override
from homeassistant.components.sensor import (
@@ -9,6 +11,8 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import (
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
@@ -17,6 +21,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import WattwaechterConfigEntry, WattwaechterCoordinator
from .entity import WattwaechterEntity
@@ -211,6 +216,38 @@ OBIS_PHASE: dict[str, str] = {
}
@dataclass(frozen=True, kw_only=True)
class WattwaechterDiagnosticSensorDescription(SensorEntityDescription):
"""Describes a WattWächter diagnostic sensor sourced from system info."""
section: str
field: str
value_fn: Callable[[str], StateType] = lambda value: value
DIAGNOSTIC_SENSORS: tuple[WattwaechterDiagnosticSensorDescription, ...] = (
WattwaechterDiagnosticSensorDescription(
key="wifi_signal",
section="wifi",
field="signal_strength",
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=int,
),
WattwaechterDiagnosticSensorDescription(
key="ssid",
translation_key="ssid",
section="wifi",
field="ssid",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: WattwaechterConfigEntry,
@@ -219,15 +256,20 @@ async def async_setup_entry(
"""Set up WattWächter sensors from a config entry."""
coordinator = entry.runtime_data
async_add_entities(
entities: list[SensorEntity] = [
WattwaechterObisSensor(
coordinator=coordinator,
description=KNOWN_OBIS_CODES[obis_code],
obis_code=obis_code,
)
for obis_code in coordinator.data.values
for obis_code in coordinator.data.meter.values
if obis_code in KNOWN_OBIS_CODES
]
entities.extend(
WattwaechterDiagnosticSensor(coordinator, description)
for description in DIAGNOSTIC_SENSORS
)
async_add_entities(entities)
class WattwaechterObisSensor(WattwaechterEntity, SensorEntity):
@@ -253,7 +295,37 @@ class WattwaechterObisSensor(WattwaechterEntity, SensorEntity):
@override
def native_value(self) -> float | str | None:
"""Return the current sensor value."""
obis = self.coordinator.data.values.get(self._obis_code)
obis = self.coordinator.data.meter.values.get(self._obis_code)
if obis is None:
return None
return obis.value
class WattwaechterDiagnosticSensor(WattwaechterEntity, SensorEntity):
"""Diagnostic sensor sourced from the device system info."""
entity_description: WattwaechterDiagnosticSensorDescription
def __init__(
self,
coordinator: WattwaechterCoordinator,
description: WattwaechterDiagnosticSensorDescription,
) -> None:
"""Initialize the diagnostic sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.device_id}_{description.key}"
@property
@override
def native_value(self) -> StateType:
"""Return the current diagnostic value."""
system = self.coordinator.data.system
if system is None:
return None
raw = system.get_value(
self.entity_description.section, self.entity_description.field
)
if not raw:
return None
return self.entity_description.value_fn(raw)
@@ -71,6 +71,7 @@
"import_tariff_2": { "name": "Consumption tariff 2" },
"import_total": { "name": "Total consumption" },
"power_factor_phase": { "name": "Power factor {phase}" },
"ssid": { "name": "SSID" },
"voltage_phase": { "name": "Voltage {phase}" }
}
},
@@ -230,6 +230,111 @@
'state': '0.985',
})
# ---
# name: test_all_entities[sensor.haushalt_test_signal_strength-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.haushalt_test_signal_strength',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Signal strength',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.SIGNAL_STRENGTH: 'signal_strength'>,
'original_icon': None,
'original_name': 'Signal strength',
'platform': 'wattwaechter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'ABC123_wifi_signal',
'unit_of_measurement': 'dBm',
})
# ---
# name: test_all_entities[sensor.haushalt_test_signal_strength-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'signal_strength',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Haushalt Test Signal strength',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: 'dBm',
}),
'context': <ANY>,
'entity_id': 'sensor.haushalt_test_signal_strength',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '-45',
})
# ---
# name: test_all_entities[sensor.haushalt_test_ssid-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.haushalt_test_ssid',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'SSID',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'SSID',
'platform': 'wattwaechter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'ssid',
'unique_id': 'ABC123_ssid',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[sensor.haushalt_test_ssid-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Haushalt Test SSID',
}),
'context': <ANY>,
'entity_id': 'sensor.haushalt_test_ssid',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'MyNetwork',
})
# ---
# name: test_all_entities[sensor.haushalt_test_total_consumption-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -36,10 +36,11 @@ async def test_diagnostics_system_info_unavailable(
mock_client: AsyncMock,
) -> None:
"""Test diagnostics still return config and meter data without system info."""
mock_client.system_info.side_effect = WattwaechterConnectionError("offline")
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
mock_client.system_info.side_effect = WattwaechterConnectionError("offline")
result = await get_diagnostics_for_config_entry(
hass, hass_client, mock_config_entry
)
@@ -8,12 +8,17 @@ from unittest.mock import AsyncMock
from aio_wattwaechter import (
WattwaechterAuthenticationError,
WattwaechterConnectionError,
WattwaechterError,
WattwaechterNoDataError,
)
import pytest
from homeassistant.components.wattwaechter.const import DOMAIN
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from .conftest import MOCK_DEVICE_ID, MOCK_HOST
from tests.common import MockConfigEntry
@@ -93,3 +98,32 @@ async def test_setup_entry_auth_error_starts_reauth(
assert len(flows) == 1
assert flows[0]["context"]["source"] == SOURCE_REAUTH
assert flows[0]["context"]["entry_id"] == mock_config_entry.entry_id
@pytest.mark.parametrize(
("system_info_error", "expected_url"),
[
(None, "http://wattwaechter-aabbccddeeff.local"),
(WattwaechterConnectionError("offline"), f"http://{MOCK_HOST}"),
],
ids=["mdns", "ip_fallback"],
)
async def test_device_configuration_url(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_client: AsyncMock,
device_registry: dr.DeviceRegistry,
system_info_error: WattwaechterError | None,
expected_url: str,
) -> None:
"""Test the configuration URL prefers the mDNS host and falls back to the IP."""
mock_client.system_info.side_effect = system_info_error
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
device = device_registry.async_get_device_by_identifier(
(DOMAIN, MOCK_DEVICE_ID), mock_config_entry.entry_id
)
assert device is not None
assert device.configuration_url == expected_url
+54 -1
View File
@@ -5,11 +5,18 @@ from __future__ import annotations
from datetime import timedelta
from unittest.mock import AsyncMock
from aio_wattwaechter import (
WattwaechterConnectionError,
WattwaechterError,
WattwaechterNotFoundError,
WattwaechterRateLimitError,
)
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.wattwaechter.const import DEFAULT_SCAN_INTERVAL, DOMAIN
from homeassistant.const import STATE_UNKNOWN
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -18,6 +25,7 @@ from .conftest import MOCK_DEVICE_ID, MOCK_METER_DATA_MINIMAL
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
@@ -59,6 +67,51 @@ async def test_minimal_meter_data(
assert _get_entity_id("31.7.0") is None
@pytest.mark.parametrize(
"error",
[
WattwaechterConnectionError("offline"),
WattwaechterRateLimitError("rate limited"),
WattwaechterNotFoundError("not found"),
],
ids=["connection", "rate_limit", "not_found"],
)
async def test_system_info_error_is_best_effort(
hass: HomeAssistant,
error: WattwaechterError,
mock_config_entry: MockConfigEntry,
mock_client: AsyncMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test a system-info failure keeps meter sensors up; diagnostics go unknown."""
mock_client.system_info.side_effect = error
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
meter_id = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{MOCK_DEVICE_ID}_1.8.0"
)
assert meter_id is not None
assert hass.states.get(meter_id).state != STATE_UNAVAILABLE
ssid_id = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{MOCK_DEVICE_ID}_ssid"
)
assert ssid_id is not None
assert (
entity_registry.async_get(ssid_id).disabled_by
is er.RegistryEntryDisabler.INTEGRATION
)
# Diagnostic sensors are disabled by default; enable and reload for a state.
entity_registry.async_update_entity(ssid_id, disabled_by=None)
await hass.config_entries.async_reload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert hass.states.get(ssid_id).state == STATE_UNKNOWN
async def test_sensor_value_unknown_when_obis_stops_reporting(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,