From 16e0cb24017aed94b48344c9fa7c804a04f3629f Mon Sep 17 00:00:00 2001 From: bvweerd Date: Mon, 28 Sep 2026 09:45:16 +0200 Subject: [PATCH 1/5] fix: correct license from Apache 2.0 to GPL v3 in docs/index.md --- docs/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/index.md b/docs/index.md index 8fa5618..a6f6faf 100644 --- a/docs/index.md +++ b/docs/index.md @@ -151,5 +151,5 @@ The dashed arrow is the part you build yourself — see ## License -Battery Controller is released under the Apache License 2.0. See -[LICENSE](https://github.com/bvweerd/battery_controller/blob/dev/LICENSE) for details. +Battery Controller is released under the GNU General Public License v3.0. See +[LICENSE](https://github.com/bvweerd/battery_controller/blob/main/LICENSE) for details. From 579764c00787d8a9643f25c8a0724e241486c0e4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:45:28 +0000 Subject: [PATCH 2/5] deps: update pymdown-extensions requirement from >=11.0.2 to >=12.1 Updates the requirements on [pymdown-extensions](https://github.com/facelessuser/pymdown-extensions) to permit the latest version. - [Release notes](https://github.com/facelessuser/pymdown-extensions/releases) - [Commits](https://github.com/facelessuser/pymdown-extensions/compare/11.0.2...12.1) --- updated-dependencies: - dependency-name: pymdown-extensions dependency-version: '12.1' dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 4417e3d..75f982a 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -9,4 +9,4 @@ mkdocs>=1.6.1 mkdocs-material>=9.7.7 -pymdown-extensions>=11.0.2 +pymdown-extensions>=12.1 From dc429857365ddfed2fe713fc8f21143c32b55b79 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Mon, 5 Oct 2026 08:53:08 +0200 Subject: [PATCH 3/5] fix: calibration blocked by commitment filter power check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _planned_first_step_was_executed compared the optimizer's planned power against the committed power (after the commitment filter). When the commitment filter locked a different power level within a price period, the difference exceeded the 10% tolerance and every discharge step was classified as plan_not_executed, preventing any calibration samples. The power check is redundant at the fleet level: the direction check already filters out mode-resolution overrides (hybrid → zero_grid, manual), and the per-battery planned_delta already uses the committed setpoint from _last_battery_setpoints — so a power difference caused by the commitment filter does not invalidate the measurement. Remove the power comparison; only the direction (effective_mode) check is needed at the fleet level. --- .../coordinator_optimization.py | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/custom_components/battery_controller/coordinator_optimization.py b/custom_components/battery_controller/coordinator_optimization.py index 31c77cb..6091f09 100644 --- a/custom_components/battery_controller/coordinator_optimization.py +++ b/custom_components/battery_controller/coordinator_optimization.py @@ -1972,20 +1972,21 @@ def _planned_first_step_was_executed(self, planned_action: str) -> bool: """Return whether the previous run's planned first step was commanded as-is. The DP plan is not always what gets executed: hybrid mode can resolve a - planned charge/discharge to zero_grid, the commitment filter can lock a - different power, and zero_grid/manual control modes ignore the schedule - entirely. Efficiency calibration must only sample steps where the plan - was sent to the controller unchanged — otherwise the actual SoC delta - reflects the mode resolution, not battery efficiency. + planned charge/discharge to zero_grid, and zero_grid/manual control modes + ignore the schedule entirely. Efficiency calibration must only sample steps + where the correct direction was sent to the controller — otherwise the actual + SoC delta reflects the mode resolution, not battery efficiency. + + The commitment filter may lock a different power level within the same price + period, but the direction is unchanged and the per-battery planned_delta + already uses the committed setpoint from _last_battery_setpoints, so a power + mismatch here does not invalidate the measurement. """ if self._effective_mode != planned_action: return False - if self._last_result is None or not self._last_result.power_schedule_kw: + if self._last_result is None: return False - planned_kw = self._last_result.power_schedule_kw[0] - executed_kw = self._controller_schedule_w / 1000 - tolerance_kw = max(0.05, 0.1 * abs(planned_kw)) - return abs(executed_kw - planned_kw) <= tolerance_kw + return True def _fleet_calibration_block(self, action: str) -> str | None: """Why no battery can be sampled for ``action`` this run, if any. From 199ec6003c464a8d8cb28505aa90a4645145f84d Mon Sep 17 00:00:00 2001 From: bvweerd Date: Wed, 7 Oct 2026 11:58:57 +0200 Subject: [PATCH 4/5] fix: calibration step_too_small due to mid-period run overwriting step duration _previous_step_hours() was reading step_durations[0] from the last published optimizer run. When a mid-period correction run (e.g. at t=7.5 min into a 15-min price step) overwrites self.data, its first step covers only the remaining ~7.5 minutes. Calibration at the period boundary then computes planned_delta against 7.5 min instead of the full 15 min, pushing it below the step_too_small_to_measure threshold even though the SoC snapshot covers the complete price step. Fix: when the SoC snapshot age exceeds the data-derived step duration, use the snapshot age as the measurement window. The snapshot is refreshed once per price step, so its age equals the full step the measurement covers. The snapshot age is only preferred when larger than data_hours, so tests and first-startup cases without a snapshot timestamp are unaffected. --- .../coordinator_optimization.py | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/custom_components/battery_controller/coordinator_optimization.py b/custom_components/battery_controller/coordinator_optimization.py index 6091f09..0c15876 100644 --- a/custom_components/battery_controller/coordinator_optimization.py +++ b/custom_components/battery_controller/coordinator_optimization.py @@ -2015,16 +2015,40 @@ def _fleet_calibration_block(self, action: str) -> str | None: def _previous_step_hours(self) -> float: """Length of the step being scored, in hours. - Falls back to the coordinator's own interval when the plan carried no - timing metadata — the same fallback _previous_step_complete makes. + Normally returns the first-step duration from the last published run. + When a mid-period correction run has overwritten self.data with a shorter + partial step, the SoC snapshot age gives the correct measurement window + instead: the snapshot is taken once per price step, so the elapsed time + since the last snapshot equals the full step that the measurement covers. + + Example: optimizer runs every 7.5 min within a 15-min price period. + At t=7.5 the mid-period run writes step_durations[0]=0.125 h. At t=15 + _previous_step_complete fires and calibration runs. data_hours=0.125 h, + but the snapshot is 15 min old (0.25 h). Using 0.125 h would halve the + planned_delta and push it below the step_too_small_to_measure threshold + even though the actual measurement window is a full price step. + + The snapshot age is only preferred when it exceeds the data-derived value, + so tests and startup cases that have no snapshot still work correctly. """ + data_hours: float | None = None if isinstance(self.data, dict): durations = self.data.get("step_durations_hours") if durations: try: - return float(durations[0]) + data_hours = float(durations[0]) except (TypeError, ValueError, IndexError): pass + + if self._soc_snapshot_time is not None and data_hours is not None: + elapsed = ( + dt_util.utcnow() - self._soc_snapshot_time + ).total_seconds() / 3600 + if elapsed > data_hours: + return elapsed + + if data_hours is not None: + return data_hours return ( float(self.config.get(CONF_TIME_STEP_MINUTES, DEFAULT_TIME_STEP_MINUTES)) / 60.0 From af12f3344a7d259600abb86956c1fcdbb0443a93 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Fri, 9 Oct 2026 15:49:40 +0200 Subject: [PATCH 5/5] fix: support Home Assistant 2026.10 type and device registry changes - Type schemas against probatio (HA 2026.10 replaced voluptuous); keep the voluptuous import at runtime for older releases. - Allow implicit re-export from homeassistant.components.binary_sensor so BinarySensorDeviceClass can still be imported from the package. - Device migration: on the single config entry device model move a device still under the main entry into its subentry instead of removing the main-entry link, which would delete the device. - Point .python-version at the Python 3.14 environment. --- .python-version | 2 +- .../battery_controller/__init__.py | 11 +++- .../battery_controller/config_flow.py | 11 +++- .../battery_controller/sensor.py | 10 +++- setup.cfg | 6 +++ tests/test_sensor.py | 50 ++++++++++++++++++- 6 files changed, 82 insertions(+), 8 deletions(-) diff --git a/.python-version b/.python-version index 01daf2a..cfeb69f 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -ha-env +ha-env-314 diff --git a/custom_components/battery_controller/__init__.py b/custom_components/battery_controller/__init__.py index b9ea902..2e78fe2 100644 --- a/custom_components/battery_controller/__init__.py +++ b/custom_components/battery_controller/__init__.py @@ -8,9 +8,8 @@ from dataclasses import dataclass, field from functools import partial from pathlib import Path -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast -import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv @@ -47,6 +46,14 @@ # older config versions fail setup with "Migration handler not found". from .config_flow import async_migrate_entry # noqa: F401 +if TYPE_CHECKING: + # Home Assistant 2026.10+ validates with probatio and aliases voluptuous to it + # at runtime, so Core's signatures expect probatio types. Older releases still + # ship voluptuous, which is why the runtime import stays as it is. + import probatio as vol +else: + import voluptuous as vol + _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) diff --git a/custom_components/battery_controller/config_flow.py b/custom_components/battery_controller/config_flow.py index f4d938d..861c351 100644 --- a/custom_components/battery_controller/config_flow.py +++ b/custom_components/battery_controller/config_flow.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import urlencode import aiohttp @@ -12,7 +12,6 @@ from homeassistant.data_entry_flow import section from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import selector -import voluptuous as vol from .const import ( BATTERY_SUBENTRY_TYPE, @@ -74,6 +73,14 @@ ) from .efficiency_curve import parse_efficiency_curve +if TYPE_CHECKING: + # Home Assistant 2026.10+ validates with probatio and aliases voluptuous to it + # at runtime, so Core's signatures expect probatio types. Older releases still + # ship voluptuous, which is why the runtime import stays as it is. + import probatio as vol +else: + import voluptuous as vol + def _build_battery_subentry_schema( defaults: dict[str, Any] | None = None, diff --git a/custom_components/battery_controller/sensor.py b/custom_components/battery_controller/sensor.py index f9ced38..bdd1b77 100644 --- a/custom_components/battery_controller/sensor.py +++ b/custom_components/battery_controller/sensor.py @@ -162,7 +162,15 @@ async def async_setup_entry( else: # Fallback for HA versions before async_get_device_by_identifier. dev = device_registry.async_get_device(identifiers={(DOMAIN, sid)}) - if dev and None in dev.config_entries_subentries.get(entry.entry_id, set()): + if dev is None: + continue + if hasattr(dev, "config_entry_id"): + # Home Assistant 2026.10+: a device belongs to exactly one config entry + # and subentry. Removing the main-entry link would delete the device, so + # a device still outside its subentry is moved into it instead. + if dev.config_entry_id == entry.entry_id and dev.config_subentry_id is None: + device_registry.async_update_device(dev.id, new_config_subentry_id=sid) + elif None in dev.config_entries_subentries.get(entry.entry_id, set()): device_registry.async_update_device( dev.id, remove_config_entry_id=entry.entry_id, diff --git a/setup.cfg b/setup.cfg index 82322a0..219e0d0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -81,3 +81,9 @@ ignore_missing_imports = true warn_incomplete_stub = true warn_redundant_casts = true warn_unused_configs = true + +# Home Assistant 2026.10 re-exports BinarySensorDeviceClass from the package's +# const module without "as"/__all__, which strict mode rejects. Core imports it +# from the package itself, and binary_sensor.const does not exist on older releases. +[mypy-homeassistant.components.binary_sensor] +implicit_reexport = true diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 4593c34..e712d48 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -1260,8 +1260,10 @@ async def test_sensor_async_setup_entry_device_migration(): hass = MagicMock() - # Mock device registry returning a device with None subentry association - mock_dev = MagicMock() + # Mock device registry returning a device with None subentry association. + # The spec leaves out config_entry_id: this is the multi-entry device model of + # Home Assistant before 2026.10. + mock_dev = MagicMock(spec=["id", "config_entries_subentries"]) mock_dev.id = "dev_id_1" mock_dev.config_entries_subentries = { entry.entry_id: {None} @@ -1281,6 +1283,50 @@ def _add(entities, **kwargs): mock_dr.async_update_device.assert_called_once() +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("config_subentry_id", "expect_move"), + [(None, True), ("sub1", False)], +) +async def test_sensor_async_setup_entry_device_migration_single_owner( + config_subentry_id, expect_move +): + """On the single config entry device model, a main-entry device is moved.""" + + opt_coord = _make_opt_coord() + forecast_coord = _make_forecast_coord() + + runtime_data = MagicMock() + runtime_data.optimization_coordinator = opt_coord + runtime_data.forecast_coordinator = forecast_coord + runtime_data.device = DeviceInfo(identifiers={(DOMAIN, "test")}) + runtime_data.battery_devices = {"sub1": DeviceInfo(identifiers={(DOMAIN, "sub1")})} + runtime_data.pv_devices = {} + + entry = _make_entry() + entry.runtime_data = runtime_data + entry.subentries = {} + + mock_dev = MagicMock(spec=["id", "config_entry_id", "config_subentry_id"]) + mock_dev.id = "dev_id_1" + mock_dev.config_entry_id = entry.entry_id + mock_dev.config_subentry_id = config_subentry_id + + mock_dr = MagicMock() + mock_dr.async_get_device_by_identifier = MagicMock(return_value=mock_dev) + mock_dr.async_update_device = MagicMock() + + with patch.object(dr, "async_get", return_value=mock_dr): + await async_setup_entry(MagicMock(), entry, lambda entities, **kwargs: None) + + if expect_move: + mock_dr.async_update_device.assert_called_once_with( + "dev_id_1", new_config_subentry_id="sub1" + ) + else: + mock_dr.async_update_device.assert_not_called() + + # --------------------------------------------------------------------------- # The contract every sensor shares: no coordinator data, no state # ---------------------------------------------------------------------------