From 62cbf4cf2a71154c7389dcd5be00314f5b3c8a8c Mon Sep 17 00:00:00 2001 From: SchaafAlexander <86607129+SchaafAlexander@users.noreply.github.com> Date: Wed, 11 Nov 2026 08:37:15 +0100 Subject: [PATCH] fix(homeassistant): apply 0 and off from config entities, never add settings The configuration sync of the Home Assistant adapter skipped every value that is false in Python, so an entity state of 0 or off never reached the configuration. It also set any path it was given: the documented example devices/batteries/0/capacity_wh named a device "0" that does not exist, so EOS silently added a new battery with a random device id. Apply every value except an unavailable or unknown entity, and only update settings that exist; report other paths as an error. The example now addresses the battery by its device_id. Signed-off-by: SchaafAlexander <86607129+SchaafAlexander@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- docs/_generated/configadapter.md | 7 +- .../adapter/adapterhomeassistant.md | 7 +- openapi.json | 8 +- src/akkudoktoreos/adapter/homeassistant.py | 22 ++++- tests/test_homeassistant.py | 94 +++++++++++++++++++ 5 files changed, 126 insertions(+), 12 deletions(-) diff --git a/docs/_generated/configadapter.md b/docs/_generated/configadapter.md index 0023fbfa..db8e00e8 100644 --- a/docs/_generated/configadapter.md +++ b/docs/_generated/configadapter.md @@ -136,7 +136,8 @@ There are two URLs that are used: | ---- | ---- | --------- | ------- | ----------- | | config_entity_ids | `Optional[dict[str, str]]` | `rw` | `None` | Mapping of EOS config keys to Home Assistant entity IDs. The config key has to be given by a ‘/’-separated path -e.g. devices/batteries/0/capacity_wh | +e.g. devices/batteries/battery1/capacity_wh +Devices are addressed by their device_id. The setting must exist. | | device_instruction_entity_ids | `Optional[list[str]]` | `rw` | `None` | Entity IDs for device (resource) instructions to be updated by EOS. The device ids (resource ids) have to be prepended by 'sensor.eos_' to build the entity_id. E.g. The instruction for device id 'battery1' becomes the entity_id 'sensor.eos_battery1'. | @@ -164,7 +165,7 @@ E.g. solution key 'battery1_idle_op_mode' becomes the entity_id 'sensor.eos_batt "adapter": { "homeassistant": { "config_entity_ids": { - "devices/batteries/0/capacity_wh": "sensor.battery1_capacity" + "devices/batteries/battery1/capacity_wh": "sensor.battery1_capacity" }, "load_emr_entity_ids": [ "sensor.load_energy_total_kwh" @@ -204,7 +205,7 @@ E.g. solution key 'battery1_idle_op_mode' becomes the entity_id 'sensor.eos_batt "adapter": { "homeassistant": { "config_entity_ids": { - "devices/batteries/0/capacity_wh": "sensor.battery1_capacity" + "devices/batteries/battery1/capacity_wh": "sensor.battery1_capacity" }, "load_emr_entity_ids": [ "sensor.load_energy_total_kwh" diff --git a/docs/akkudoktoreos/adapter/adapterhomeassistant.md b/docs/akkudoktoreos/adapter/adapterhomeassistant.md index f9b5211f..a90513cf 100644 --- a/docs/akkudoktoreos/adapter/adapterhomeassistant.md +++ b/docs/akkudoktoreos/adapter/adapterhomeassistant.md @@ -148,6 +148,11 @@ by Home Assistant integrations, such as: These values are typically consumed by EOS **device simulations** during optimisation. +The configuration key is the `/`-separated path of the setting. Devices are addressed by their +`device_id`, e.g. `devices/batteries/battery1/capacity_wh`. Only existing settings are updated: a +path that names no setting, e.g. a device that is not configured, is reported as an error in the +EOS log. An entity that is unavailable or unknown leaves the configured value unchanged. + ### 3.2 Device measurement entity IDs EOS retrieves **measurement values** that describe the *current state* of devices, such as: @@ -179,7 +184,7 @@ EOS value: * **Boolean `True`**: `["y", "yes", "on", "true", "home", "open"]` * **Boolean `False`**: `["n", "no", "off", "false", "closed"]` -* **`None`**: `["unavailable", "none"]` +* **`None`**: `["unavailable", "unknown", "none"]` * **`float`**: if the value can be converted to a floating-point number * **`str`**: if none of the above apply diff --git a/openapi.json b/openapi.json index ba05462f..bc1a67cd 100644 --- a/openapi.json +++ b/openapi.json @@ -11398,10 +11398,10 @@ } ], "title": "Config Entity Ids", - "description": "Mapping of EOS config keys to Home Assistant entity IDs.\nThe config key has to be given by a \u2018/\u2019-separated path\ne.g. devices/batteries/0/capacity_wh", + "description": "Mapping of EOS config keys to Home Assistant entity IDs.\nThe config key has to be given by a \u2018/\u2019-separated path\ne.g. devices/batteries/battery1/capacity_wh\nDevices are addressed by their device_id. The setting must exist.", "examples": [ { - "devices/batteries/0/capacity_wh": "sensor.battery1_capacity" + "devices/batteries/battery1/capacity_wh": "sensor.battery1_capacity" } ] }, @@ -11574,10 +11574,10 @@ } ], "title": "Config Entity Ids", - "description": "Mapping of EOS config keys to Home Assistant entity IDs.\nThe config key has to be given by a \u2018/\u2019-separated path\ne.g. devices/batteries/0/capacity_wh", + "description": "Mapping of EOS config keys to Home Assistant entity IDs.\nThe config key has to be given by a \u2018/\u2019-separated path\ne.g. devices/batteries/battery1/capacity_wh\nDevices are addressed by their device_id. The setting must exist.", "examples": [ { - "devices/batteries/0/capacity_wh": "sensor.battery1_capacity" + "devices/batteries/battery1/capacity_wh": "sensor.battery1_capacity" } ] }, diff --git a/src/akkudoktoreos/adapter/homeassistant.py b/src/akkudoktoreos/adapter/homeassistant.py index b0201f49..4643143f 100644 --- a/src/akkudoktoreos/adapter/homeassistant.py +++ b/src/akkudoktoreos/adapter/homeassistant.py @@ -39,11 +39,12 @@ class HomeAssistantAdapterCommonSettings(SettingsBaseModel): "description": ( "Mapping of EOS config keys to Home Assistant entity IDs.\n" "The config key has to be given by a ‘/’-separated path\n" - "e.g. devices/batteries/0/capacity_wh" + "e.g. devices/batteries/battery1/capacity_wh\n" + "Devices are addressed by their device_id. The setting must exist." ), "examples": [ { - "devices/batteries/0/capacity_wh": "sensor.battery1_capacity", + "devices/batteries/battery1/capacity_wh": "sensor.battery1_capacity", } ], }, @@ -484,8 +485,21 @@ class HomeAssistantAdapter(AdapterProvider): state = self.get_entity_state(entity_id) logger.debug(f"Entity {entity_id}: {state}") value = self._convert_entity_state(state) - if value: - self.config.set_nested_value(config_key, value) + if value is None: + # Entity unavailable or unknown: keep the configured value. + # 0 and False are values and are applied. + continue + try: + # Only update an existing setting. Setting a path that names + # no setting, e.g. a device that is not configured, would + # silently add a new device. + self.config.get_nested_value(config_key) + except (AttributeError, IndexError, KeyError) as e: + raise ValueError( + f"Config key '{config_key}' of entity '{entity_id}' is not " + f"an existing setting: {e}" + ) from e + self.config.set_nested_value(config_key, value) except Exception as e: logger.error(f"{e}") diff --git a/tests/test_homeassistant.py b/tests/test_homeassistant.py index 495d8cc0..eff56d14 100644 --- a/tests/test_homeassistant.py +++ b/tests/test_homeassistant.py @@ -427,3 +427,97 @@ class TestHomeAssistantDeviceMeasurementKeys: registered_keys = {"battery-1-soc-factor", "battery_1-soc-factor"} assert HomeAssistantAdapter._resolve_device_measurement_key(key, registered_keys) == key + + +class _ConfigEntityHarness: + """Run the configuration sync of the adapter against a real EOS configuration.""" + + _update_data = HomeAssistantAdapter._update_data + _convert_entity_state = HomeAssistantAdapter._convert_entity_state + + def __init__(self, config, states: dict[str, str]) -> None: + self.config = config + self.ems = SimpleNamespace( + stage=lambda: EnergyManagementStage.DATA_ACQUISITION, + observation_datetime=object(), + ) + self.get_entity_state = lambda entity_id: states[entity_id] + + +class TestHomeAssistantConfigEntityIds: + @pytest.fixture(autouse=True) + def cec_databases(self): + """This adapter test needs no CEC reference-device data.""" + yield None + + @pytest.fixture + def config(self, config_eos): + config_eos.merge_settings_from_dict( + { + "devices": { + "batteries": { + "battery1": { + "device_id": "battery1", + "capacity_wh": 8000, + "min_soc_percentage": 10, + } + } + } + } + ) + return config_eos + + async def _sync(self, config, entity_ids: dict[str, str], states: dict[str, str]) -> None: + config.adapter.homeassistant.config_entity_ids = entity_ids + adapter = _ConfigEntityHarness(config, states) + await HomeAssistantAdapter._update_data(cast(HomeAssistantAdapter, adapter)) + + @pytest.mark.asyncio + async def test_sets_value_by_device_id(self, config) -> None: + await self._sync( + config, + {"devices/batteries/battery1/capacity_wh": "sensor.capacity"}, + {"sensor.capacity": "12000"}, + ) + assert config.devices.batteries["battery1"].capacity_wh == 12000 + + @pytest.mark.asyncio + async def test_applies_zero(self, config) -> None: + """0 is a value, not a missing state.""" + await self._sync( + config, + {"devices/batteries/battery1/min_soc_percentage": "sensor.min_soc"}, + {"sensor.min_soc": "0"}, + ) + assert config.devices.batteries["battery1"].min_soc_percentage == 0 + + @pytest.mark.asyncio + @pytest.mark.parametrize("state", ["unavailable", "unknown"]) + async def test_keeps_value_of_unavailable_entity(self, config, state) -> None: + await self._sync( + config, + {"devices/batteries/battery1/capacity_wh": "sensor.capacity"}, + {"sensor.capacity": state}, + ) + assert config.devices.batteries["battery1"].capacity_wh == 8000 + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "config_key", + [ + "devices/batteries/0/capacity_wh", + "devices/batteries/battery2/capacity_wh", + "devices/batteries/battery1/no_such_setting", + ], + ) + async def test_does_not_add_unknown_setting(self, config, config_key, monkeypatch) -> None: + """A path that names no existing setting must not add a device.""" + mock_logger = Mock() + monkeypatch.setattr(homeassistant_module, "logger", mock_logger) + + await self._sync(config, {config_key: "sensor.capacity"}, {"sensor.capacity": "12000"}) + + assert list(config.devices.batteries) == ["battery1"] + assert config.devices.batteries["battery1"].capacity_wh == 8000 + mock_logger.error.assert_called_once() + assert config_key in mock_logger.error.call_args.args[0]