diff --git a/homeassistant/components/wattwaechter/coordinator.py b/homeassistant/components/wattwaechter/coordinator.py index 30ca2b54e321..82b6bb3bdd54 100644 --- a/homeassistant/components/wattwaechter/coordinator.py +++ b/homeassistant/components/wattwaechter/coordinator.py @@ -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) diff --git a/homeassistant/components/wattwaechter/diagnostics.py b/homeassistant/components/wattwaechter/diagnostics.py index a59cfaa9df29..6c4b021d571e 100644 --- a/homeassistant/components/wattwaechter/diagnostics.py +++ b/homeassistant/components/wattwaechter/diagnostics.py @@ -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, ) diff --git a/homeassistant/components/wattwaechter/entity.py b/homeassistant/components/wattwaechter/entity.py index fdb70f176f85..508f584edfe8 100644 --- a/homeassistant/components/wattwaechter/entity.py +++ b/homeassistant/components/wattwaechter/entity.py @@ -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}", ) diff --git a/homeassistant/components/wattwaechter/icons.json b/homeassistant/components/wattwaechter/icons.json index 1b1cb54ad376..4a02f1ec7ef7 100644 --- a/homeassistant/components/wattwaechter/icons.json +++ b/homeassistant/components/wattwaechter/icons.json @@ -18,6 +18,9 @@ }, "import_total": { "default": "mdi:meter-electric" + }, + "ssid": { + "default": "mdi:access-point" } } } diff --git a/homeassistant/components/wattwaechter/quality_scale.yaml b/homeassistant/components/wattwaechter/quality_scale.yaml index ead33c6cdb41..de556582302e 100644 --- a/homeassistant/components/wattwaechter/quality_scale.yaml +++ b/homeassistant/components/wattwaechter/quality_scale.yaml @@ -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: diff --git a/homeassistant/components/wattwaechter/sensor.py b/homeassistant/components/wattwaechter/sensor.py index 438afceff711..a427304ced72 100644 --- a/homeassistant/components/wattwaechter/sensor.py +++ b/homeassistant/components/wattwaechter/sensor.py @@ -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) diff --git a/homeassistant/components/wattwaechter/strings.json b/homeassistant/components/wattwaechter/strings.json index b314a97721cd..a1b8ed8d4e73 100644 --- a/homeassistant/components/wattwaechter/strings.json +++ b/homeassistant/components/wattwaechter/strings.json @@ -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}" } } }, diff --git a/tests/components/wattwaechter/snapshots/test_sensor.ambr b/tests/components/wattwaechter/snapshots/test_sensor.ambr index 09318e4724dd..701349e8cf1d 100644 --- a/tests/components/wattwaechter/snapshots/test_sensor.ambr +++ b/tests/components/wattwaechter/snapshots/test_sensor.ambr @@ -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({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.haushalt_test_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Signal strength', + 'options': dict({ + }), + 'original_device_class': , + '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({ + : 'signal_strength', + : 'Haushalt Test Signal strength', + : , + : 'dBm', + }), + 'context': , + 'entity_id': 'sensor.haushalt_test_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-45', + }) +# --- +# name: test_all_entities[sensor.haushalt_test_ssid-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.haushalt_test_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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({ + : 'Haushalt Test SSID', + }), + 'context': , + 'entity_id': 'sensor.haushalt_test_ssid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'MyNetwork', + }) +# --- # name: test_all_entities[sensor.haushalt_test_total_consumption-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/wattwaechter/test_diagnostics.py b/tests/components/wattwaechter/test_diagnostics.py index 6c67343b6a8d..9805c67e2c23 100644 --- a/tests/components/wattwaechter/test_diagnostics.py +++ b/tests/components/wattwaechter/test_diagnostics.py @@ -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 ) diff --git a/tests/components/wattwaechter/test_init.py b/tests/components/wattwaechter/test_init.py index 624c15ddb75c..1b2f32b29f79 100644 --- a/tests/components/wattwaechter/test_init.py +++ b/tests/components/wattwaechter/test_init.py @@ -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 diff --git a/tests/components/wattwaechter/test_sensor.py b/tests/components/wattwaechter/test_sensor.py index d20615393038..c753aa610366 100644 --- a/tests/components/wattwaechter/test_sensor.py +++ b/tests/components/wattwaechter/test_sensor.py @@ -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,