From 727f20fdf0dbd07bb74cdc2a38a8fd34f71c24e2 Mon Sep 17 00:00:00 2001 From: Wu Shuwen Date: Thu, 29 Oct 2026 02:04:40 +0800 Subject: [PATCH] fix(homeassistant): resolve legacy device measurement keys Resolve legacy underscore spellings for Home Assistant device measurements only when they uniquely match a registered EOS key; preserve canonical, custom, and ambiguous keys. Correct the schema examples to show canonical hyphenated keys. Add a synthetic regression test that exercises data acquisition without contacting Home Assistant. --- src/akkudoktoreos/adapter/homeassistant.py | 43 +++++++++- tests/test_homeassistant.py | 92 +++++++++++++++++++++- 2 files changed, 130 insertions(+), 5 deletions(-) diff --git a/src/akkudoktoreos/adapter/homeassistant.py b/src/akkudoktoreos/adapter/homeassistant.py index 05e6f0d1..7dbe3d8d 100644 --- a/src/akkudoktoreos/adapter/homeassistant.py +++ b/src/akkudoktoreos/adapter/homeassistant.py @@ -6,7 +6,7 @@ from typing import Optional, Union import pandas as pd import requests from loguru import logger -from pydantic import Field, computed_field, field_validator +from pydantic import Field, PrivateAttr, computed_field, field_validator from akkudoktoreos.adapter.adapterabc import AdapterProvider from akkudoktoreos.config.configabc import SettingsBaseModel @@ -94,11 +94,15 @@ class HomeAssistantAdapterCommonSettings(SettingsBaseModel): device_measurement_entity_ids: Optional[dict[str, str]] = Field( default=None, json_schema_extra={ - "description": "Mapping of EOS measurement keys used by device (resource) simulations to Home Assistant entity IDs.", + "description": ( + "Mapping of EOS device measurement keys to Home Assistant entity IDs. " + "Registered keys such as 'battery1-soc-factor' are accepted; legacy " + "underscore spellings are resolved when they uniquely match a registered key." + ), "examples": [ { - "ev11_soc_factor": "sensor.ev11_soc_factor", - "battery1_soc_factor": "sensor.battery1_soc_factor", + "ev11-soc-factor": "sensor.ev11_soc_factor", + "battery1-soc-factor": "sensor.battery1_soc_factor", } ], }, @@ -219,6 +223,8 @@ class HomeAssistantAdapterCommonSettings(SettingsBaseModel): class HomeAssistantAdapter(AdapterProvider): + _legacy_device_measurement_key_warning_emitted: bool = PrivateAttr(default=False) + @classmethod def provider_id(cls) -> str: """Return the unique identifier for the adapter provider.""" @@ -276,6 +282,22 @@ class HomeAssistantAdapter(AdapterProvider): solution_entity_ids = [] return solution_entity_ids + @staticmethod + def _resolve_device_measurement_key(measurement_key: str, registered_keys: set[str]) -> str: + """Resolve a legacy underscore alias to one registered device measurement key.""" + if measurement_key in registered_keys: + return measurement_key + + aliases = {key for key in registered_keys if key.replace("-", "_") == measurement_key} + if len(aliases) == 1: + return next(iter(aliases)) + if len(aliases) > 1: + logger.warning( + "Ambiguous legacy device measurement key '{}'; leaving it unchanged.", + measurement_key, + ) + return measurement_key + def _entity_id_from_resource_id(self, resource_id: str) -> str: return HOMEASSISTANT_ENTITY_ID_PREFIX + resource_id @@ -461,10 +483,23 @@ class HomeAssistantAdapter(AdapterProvider): # Retrieve measurements necessary for device simulations entity_ids = self.config.adapter.homeassistant.device_measurement_entity_ids if entity_ids: + registered_measurement_keys = set(self.config.devices.measurement_keys) for ( measurement_key, entity_id, ) in entity_ids.items(): + resolved_measurement_key = self._resolve_device_measurement_key( + measurement_key, registered_measurement_keys + ) + if resolved_measurement_key != measurement_key: + if not self._legacy_device_measurement_key_warning_emitted: + logger.warning( + "Legacy device measurement key '{}' mapped to registered key '{}'.", + measurement_key, + resolved_measurement_key, + ) + self._legacy_device_measurement_key_warning_emitted = True + measurement_key = resolved_measurement_key if entity_id: try: state = self.get_entity_state(entity_id) diff --git a/tests/test_homeassistant.py b/tests/test_homeassistant.py index ef8798d4..495d8cc0 100644 --- a/tests/test_homeassistant.py +++ b/tests/test_homeassistant.py @@ -1,12 +1,18 @@ import os import subprocess from pathlib import Path -from typing import Optional +from types import SimpleNamespace +from typing import Optional, cast +from unittest.mock import AsyncMock, Mock import pytest import yaml from pydantic import ValidationError +import akkudoktoreos.adapter.homeassistant as homeassistant_module +from akkudoktoreos.adapter.homeassistant import HomeAssistantAdapter +from akkudoktoreos.core.ems import EnergyManagementStage + class TestHomeAssistantAddon: """Tests to ensure the repository root is a valid Home Assistant add-on. @@ -337,3 +343,87 @@ class TestHomeAssistantAddon: assert ports[port_key] is None, "Ingress port must be unpublished by default" print(f"✓ Ingress configuration valid (port {ingress_port})") + + +class _HomeAssistantAdapterHarness: + """Provide synthetic adapter dependencies without a Home Assistant instance.""" + + _update_data = HomeAssistantAdapter._update_data + _resolve_device_measurement_key = staticmethod( + HomeAssistantAdapter._resolve_device_measurement_key + ) + + def __init__(self, configured_key: str, registered_keys: set[str]) -> None: + homeassistant = SimpleNamespace( + config_entity_ids=None, + device_measurement_entity_ids={configured_key: "sensor.synthetic_battery_soc"}, + load_emr_entity_ids=None, + grid_export_emr_entity_ids=None, + grid_import_emr_entity_ids=None, + pv_production_emr_entity_ids=None, + ) + self.config = SimpleNamespace( + adapter=SimpleNamespace(homeassistant=homeassistant), + devices=SimpleNamespace(measurement_keys=list(registered_keys)), + ) + self.ems = SimpleNamespace( + stage=lambda: EnergyManagementStage.DATA_ACQUISITION, + observation_datetime=object(), + ) + self.ems_start_datetime = object() + self.measurement = SimpleNamespace(update_value=AsyncMock()) + self.get_entity_state = lambda _entity_id: "0.75" + self._legacy_device_measurement_key_warning_emitted = False + + +class TestHomeAssistantDeviceMeasurementKeys: + @pytest.fixture(autouse=True) + def cec_databases(self): + """This adapter test needs no CEC reference-device data.""" + yield None + + @pytest.mark.asyncio + async def test_maps_legacy_key_at_observation_time(self) -> None: + adapter = _HomeAssistantAdapterHarness( + "battery1_soc_factor", {"battery1-soc-factor"} + ) + + await HomeAssistantAdapter._update_data(cast(HomeAssistantAdapter, adapter)) + + adapter.measurement.update_value.assert_awaited_once_with( + adapter.ems.observation_datetime, "battery1-soc-factor", 0.75 + ) + assert adapter.ems.observation_datetime is not adapter.ems_start_datetime + + @pytest.mark.asyncio + async def test_warns_only_once_when_mapping_legacy_key(self, monkeypatch) -> None: + adapter = _HomeAssistantAdapterHarness( + "battery1_soc_factor", {"battery1-soc-factor"} + ) + mock_logger = Mock() + monkeypatch.setattr(homeassistant_module, "logger", mock_logger) + + await HomeAssistantAdapter._update_data(cast(HomeAssistantAdapter, adapter)) + await HomeAssistantAdapter._update_data(cast(HomeAssistantAdapter, adapter)) + + mock_logger.warning.assert_called_once_with( + "Legacy device measurement key '{}' mapped to registered key '{}'.", + "battery1_soc_factor", + "battery1-soc-factor", + ) + + def test_preserves_canonical_key(self) -> None: + assert HomeAssistantAdapter._resolve_device_measurement_key( + "battery1-soc-factor", {"battery1-soc-factor"} + ) == "battery1-soc-factor" + + def test_preserves_unknown_custom_key(self) -> None: + assert HomeAssistantAdapter._resolve_device_measurement_key( + "custom_measurement", {"battery1-soc-factor"} + ) == "custom_measurement" + + def test_does_not_guess_ambiguous_legacy_key(self) -> None: + key = "battery_1_soc_factor" + registered_keys = {"battery-1-soc-factor", "battery_1-soc-factor"} + + assert HomeAssistantAdapter._resolve_device_measurement_key(key, registered_keys) == key