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.
This commit is contained in:
Wu Shuwen
2026-10-28 19:04:40 +01:00
committed by GitHub
parent 63e7e7e729
commit 727f20fdf0
2 changed files with 130 additions and 5 deletions
+39 -4
View File
@@ -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)
+91 -1
View File
@@ -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