mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-29 09:11:57 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user