Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .python-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
ha-env
ha-env-314
11 changes: 9 additions & 2 deletions custom_components/battery_controller/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 9 additions & 2 deletions custom_components/battery_controller/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
51 changes: 38 additions & 13 deletions custom_components/battery_controller/coordinator_optimization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion custom_components/battery_controller/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion requirements-docs.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@

mkdocs>=1.6.1
mkdocs-material>=9.7.7
pymdown-extensions>=11.0.2
pymdown-extensions>=12.1
6 changes: 6 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
50 changes: 48 additions & 2 deletions tests/test_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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
# ---------------------------------------------------------------------------
Expand Down
Loading