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 pandas as pd
|
||||||
import requests
|
import requests
|
||||||
from loguru import logger
|
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.adapter.adapterabc import AdapterProvider
|
||||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||||
@@ -94,11 +94,15 @@ class HomeAssistantAdapterCommonSettings(SettingsBaseModel):
|
|||||||
device_measurement_entity_ids: Optional[dict[str, str]] = Field(
|
device_measurement_entity_ids: Optional[dict[str, str]] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
json_schema_extra={
|
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": [
|
"examples": [
|
||||||
{
|
{
|
||||||
"ev11_soc_factor": "sensor.ev11_soc_factor",
|
"ev11-soc-factor": "sensor.ev11_soc_factor",
|
||||||
"battery1_soc_factor": "sensor.battery1_soc_factor",
|
"battery1-soc-factor": "sensor.battery1_soc_factor",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -219,6 +223,8 @@ class HomeAssistantAdapterCommonSettings(SettingsBaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class HomeAssistantAdapter(AdapterProvider):
|
class HomeAssistantAdapter(AdapterProvider):
|
||||||
|
_legacy_device_measurement_key_warning_emitted: bool = PrivateAttr(default=False)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def provider_id(cls) -> str:
|
def provider_id(cls) -> str:
|
||||||
"""Return the unique identifier for the adapter provider."""
|
"""Return the unique identifier for the adapter provider."""
|
||||||
@@ -276,6 +282,22 @@ class HomeAssistantAdapter(AdapterProvider):
|
|||||||
solution_entity_ids = []
|
solution_entity_ids = []
|
||||||
return 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:
|
def _entity_id_from_resource_id(self, resource_id: str) -> str:
|
||||||
return HOMEASSISTANT_ENTITY_ID_PREFIX + resource_id
|
return HOMEASSISTANT_ENTITY_ID_PREFIX + resource_id
|
||||||
|
|
||||||
@@ -461,10 +483,23 @@ class HomeAssistantAdapter(AdapterProvider):
|
|||||||
# Retrieve measurements necessary for device simulations
|
# Retrieve measurements necessary for device simulations
|
||||||
entity_ids = self.config.adapter.homeassistant.device_measurement_entity_ids
|
entity_ids = self.config.adapter.homeassistant.device_measurement_entity_ids
|
||||||
if entity_ids:
|
if entity_ids:
|
||||||
|
registered_measurement_keys = set(self.config.devices.measurement_keys)
|
||||||
for (
|
for (
|
||||||
measurement_key,
|
measurement_key,
|
||||||
entity_id,
|
entity_id,
|
||||||
) in entity_ids.items():
|
) 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:
|
if entity_id:
|
||||||
try:
|
try:
|
||||||
state = self.get_entity_state(entity_id)
|
state = self.get_entity_state(entity_id)
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
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 pytest
|
||||||
import yaml
|
import yaml
|
||||||
from pydantic import ValidationError
|
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:
|
class TestHomeAssistantAddon:
|
||||||
"""Tests to ensure the repository root is a valid Home Assistant add-on.
|
"""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"
|
assert ports[port_key] is None, "Ingress port must be unpublished by default"
|
||||||
|
|
||||||
print(f"✓ Ingress configuration valid (port {ingress_port})")
|
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