mirror of
https://github.com/home-assistant/core.git
synced 2026-09-26 01:11:51 -04:00
Add diagnostic sensors to WattWächter Plus (#177150)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user