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/coordinator_optimization.py b/custom_components/battery_controller/coordinator_optimization.py index 31c77cb..0c15876 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. @@ -2014,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 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/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. 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 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 # ---------------------------------------------------------------------------