251 files

This commit is contained in:
Home Assistant Version Control
2026-10-16 12:38:25 +00:00
parent 3acf0cdf8d
commit 64eb0c647c
251 changed files with 6380 additions and 14560 deletions
@@ -142,6 +142,46 @@ def note_sibling_entity(hass: HomeAssistant, entity_id: str, *, domain: str, uid
return None
def _offline_note_attributes(hass: HomeAssistant, state: Any, *, binary: bool) -> dict[str, Any] | None:
"""The attributes of a Battery Notes plus entity that lost them, rebuilt
from its siblings — or None when ``state`` is no such entity.
Battery Notes 3.7.2 makes ``…_battery_plus`` and ``…_battery_plus_low``
unavailable while their source is (unless the note's *retain state* is
on), and Home Assistant writes no attributes for an unavailable entity.
Up to 3.7.1 they read ``unknown`` and kept them. The note itself lives on
in its type sensor (type and quantity) and its last-replaced sensor, both
independent of the source, so the battery stays in the fleet as offline
with its forecast instead of vanishing when it most likely just died.
``battery_low`` stays unknown here; the low latch carries it over.
"""
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
if str(state.state).lower() not in _NO_READING:
return None
reg = er.async_get(hass).async_get(state.entity_id)
suffix = "_battery_low" if binary else "_battery_plus"
if reg is None or reg.platform != "battery_notes" or not isinstance(reg.unique_id, str) or not reg.unique_id.endswith(suffix):
return None
attrs: dict[str, Any] = dict(state.attributes)
type_eid = note_sibling_entity(hass, state.entity_id, domain="sensor", uid_suffix="_battery_type")
type_state = hass.states.get(type_eid) if type_eid else None
if type_state is not None and _is_type_note(type_state):
attrs["battery_type"] = type_state.attributes.get("battery_type")
attrs["battery_quantity"] = type_state.attributes.get("battery_quantity")
else:
attrs["battery_type"] = "Unknown"
last_eid = note_sibling_entity(hass, state.entity_id, domain="sensor", uid_suffix="_battery_last_replaced")
last_state = hass.states.get(last_eid) if last_eid else None
if last_state is not None and str(last_state.state).lower() not in _NO_READING:
attrs["battery_last_replaced"] = last_state.state
device = dr.async_get(hass).async_get(reg.device_id) if reg.device_id else None
if device is not None and (device.name_by_user or device.name):
attrs["device_name"] = device.name_by_user or device.name
return attrs
def _native_snapshot_cache(hass: HomeAssistant) -> dict[str, dict[str, Any]]:
"""Runtime cache of last-known native battery readings (per entity_id)."""
from ..const import DOMAIN
@@ -197,6 +237,10 @@ class Battery:
# ``battery_recovered_percent`` (and no replacement was recorded) since
# it went low. A level hovering around the floor stays ONE low episode.
latched: bool = False
# The source entity of a Battery Notes ENTITY note ("" for a device note
# or a native row): ``set_battery_replaced`` reaches such a note only
# through it, by device it prefers the device's own note (BN 3.7.2).
note_source: str = ""
@dataclass
@@ -300,7 +344,8 @@ def build_overview(
source, confidence = "typical", None
# B1 (decided 2026-08): a PASSED prediction while the battery still
# reports healthy is "forecast overdue" — it stays in `soon` (clamped
# to 0 days, no negative countdowns) and NEVER escalates into `low`
# to 0 days, no negative countdowns; `forecast_days` keeps the real,
# passed date for the roster) and NEVER escalates into `low`
# or the task trigger: the forecast has error bars, the sensor says
# fine, and the usual cause is a swap that was never recorded (the
# roster's unrecorded-swap hint covers exactly that). Reality fires
@@ -313,7 +358,7 @@ def build_overview(
# why it is due: keep the (negative) days and the overdue flag so
# the roster can show the date the prediction ran out.
if bat.no_sensor:
low_row = _row(bat, t, days_raw, source, confidence, rechargeable=rechargeable, forecast_overdue=overdue, lifetime=info)
low_row = _row(bat, t, days_raw, source, confidence, rechargeable=rechargeable, forecast_overdue=overdue, lifetime=info, forecast_days=days_raw)
else:
low_row = _row(bat, t, None, rechargeable=rechargeable, lifetime=info)
ov.low.append(low_row)
@@ -322,12 +367,13 @@ def build_overview(
ov.all.append({**low_row, "status": "low"})
continue
if days is not None and days <= horizon_days:
ov.soon.append(_row(bat, t, days, source, confidence, rechargeable=rechargeable, forecast_overdue=overdue, lifetime=info))
soon_row = _row(bat, t, days, source, confidence, rechargeable=rechargeable, forecast_overdue=overdue, lifetime=info, forecast_days=days_raw)
ov.soon.append(soon_row)
if shoppable:
ov.needs_soon[t] = ov.needs_soon.get(t, 0) + bat.quantity
ov.all.append({**_row(bat, t, days, source, confidence, rechargeable=rechargeable, forecast_overdue=overdue, lifetime=info), "status": "soon"})
ov.all.append({**soon_row, "status": "soon"})
continue
ov.all.append({**_row(bat, t, days, source, confidence, rechargeable=rechargeable, lifetime=info), "status": "ok"})
ov.all.append({**_row(bat, t, days, source, confidence, rechargeable=rechargeable, lifetime=info, forecast_days=days_raw), "status": "ok"})
ov.soon.sort(key=lambda r: r["days_until"] if r["days_until"] is not None else 1 << 30)
ov.types = sorted(types_seen)
@@ -346,6 +392,7 @@ def _row(
rechargeable: bool = False,
forecast_overdue: bool = False,
lifetime: LifetimeInfo | None = None,
forecast_days: int | None = None,
) -> dict[str, Any]:
return {
"entity_id": bat.entity_id,
@@ -376,6 +423,12 @@ def _row(
# carries error bars and the sensor says fine — but the roster shows
# the discrepancy (common cause: a swap that was never recorded).
"forecast_overdue": forecast_overdue,
# The same forecast, NOT clamped: negative once the date has passed.
# `days_until` holds at 0 for the sort and the horizon (B1), so a
# roster that dated from it showed today's date on every overdue row,
# moving on by a day each day (prod report 2026-10-13). The ~date is
# drawn from this one; null where the row shows no forecast.
"forecast_days": forecast_days,
# D#162: no level source at all — the roster shows a "No sensor" chip
# instead of a level bar, and the replaced button is the row's action.
"no_sensor": bat.no_sensor,
@@ -521,8 +574,10 @@ def get_battery_auto_record_recovery(hass: HomeAssistant) -> bool:
# date moves forward (a recorded replacement releases it at any level), or the
# Replaced action / the record-replacement command releases it directly.
#
# Level-less rows (a low-only binary, a native binary battery) are NOT
# latched: their binary saying "not low" is the all-clear, exactly as before.
# Level-less rows are latched only to bridge an outage: a Battery Notes low
# binary (#121) stays low while it is unavailable (Battery Notes 3.7.2 drops
# its attributes then), and its "not low" is the all-clear once it reads
# again. A native binary battery is not latched (it has the native retention).
# Sensorless notes (D#162) leave ``low`` only when their forecast re-anchors.
#
# #205: each entry also keeps the low threshold it latched at. A threshold
@@ -594,6 +649,7 @@ def apply_low_latch(
now_iso: str,
eligible: set[str] | None = None,
released_by_level: list[str] | None = None,
levelless: set[str] | None = None,
) -> bool:
"""Pure latch step: mutate ``bat.low``/``bat.latched`` and the ``latch``
map in place. Returns True when the map changed (the caller persists).
@@ -614,13 +670,19 @@ def apply_low_latch(
LEVEL alone — no newer replacement date on the note — which is the
auto-record hook's input: a recorded date already counts as recorded.
``levelless`` = ids without a level whose low comes from a binary (a
Battery Notes low binary, #121). They latch only to bridge an outage:
unavailable they stay low, and once they read again their "not low" is
the all-clear (never an auto-record: no level rose).
Entries for batteries no longer in the fleet are dropped.
"""
changed = False
if eligible is None:
eligible = {b.entity_id for b in batteries if b.level is not None and not b.no_sensor}
levelless = levelless or set()
for bat in batteries:
if bat.entity_id not in eligible:
if bat.entity_id not in eligible and bat.entity_id not in levelless:
continue
last = bat.last_replaced.isoformat() if bat.last_replaced else None
raw_entry = latch.get(bat.entity_id)
@@ -638,6 +700,10 @@ def apply_low_latch(
continue
if entry is None:
continue
if bat.available and bat.entity_id in levelless:
del latch[bat.entity_id]
changed = True
continue
if bat.available and (_replaced_since(last, entry) or (bat.level is not None and bat.level > recovered)):
if released_by_level is not None and not _replaced_since(last, entry):
released_by_level.append(bat.entity_id)
@@ -683,7 +749,7 @@ def _latch_backend(hass: HomeAssistant) -> tuple[dict[str, Any], Callable[[], No
return memory, lambda: None
def _apply_low_latch(hass: HomeAssistant, batteries: list[Battery], eligible: set[str]) -> None:
def _apply_low_latch(hass: HomeAssistant, batteries: list[Battery], eligible: set[str], levelless: set[str] | None = None) -> None:
latch, save = _latch_backend(hass)
released: list[str] = []
if apply_low_latch(
@@ -693,6 +759,7 @@ def _apply_low_latch(hass: HomeAssistant, batteries: list[Battery], eligible: se
now_iso=dt_util.utcnow().isoformat(),
eligible=eligible,
released_by_level=released,
levelless=levelless,
):
# Persist BEFORE scheduling: the record path re-reads the fleet and
# must see the entry gone (or it would release — and schedule — again).
@@ -853,9 +920,13 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
* **Battery Notes** ``battery_plus`` sensors (device_class ``battery`` + a
``battery_type`` attribute) give the rich view: type, quantity, low,
last-replaced. When the source goes offline the sensor reads
unavailable/unknown but RETAINS its last-known ``battery_low`` — so a
dead battery that took its device offline stays visible. A device whose
last-replaced. When the source goes offline, Battery Notes up to
3.7.1 reads ``unknown`` and keeps its attributes; from 3.7.2 it reads
``unavailable`` WITHOUT them (unless the note retains its state), and
the note is rebuilt from its type and last-replaced sensors
(:func:`_offline_note_attributes`) while the low latch keeps it low —
either way a dead battery that took its device offline stays visible.
A device whose
source reports no percentage at all (a Matter lock with only a
battery-low binary, #121) gets NO percentage sensor from Battery Notes —
its metadata lives solely on the ``…_battery_plus_low`` BINARY, so a
@@ -933,8 +1004,14 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
covered_sources: set[str] = set()
covered_devices: set[str] = set()
# #180: rows whose ``low`` is decided by a LEVEL — the recovery latch
# applies to these only (a binary's "off" is its own all-clear).
# applies to these (a binary's "off" is its own all-clear) ...
latch_eligible: set[str] = set()
# ... and Battery Notes' low binaries (#121), latched only to bridge an
# outage: unavailable they carry no low flag any more (BN 3.7.2).
latch_levelless: set[str] = set()
# A latched battery is never dropped as connectivity noise below: it was
# low, and going offline is no proof it was replaced.
latched_now = set(_latch_backend(hass)[0])
# ── Pass 1: Battery Notes battery_plus ──────────────────────────────────
# Percentage SENSORS first, then LOW-ONLY BINARIES (#121): a source with
@@ -953,9 +1030,16 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
note_devices_all: set[str] = set()
for domain, binary_pass in (("sensor", False), ("binary_sensor", True)):
for state in hass.states.async_all(domain):
attrs = state.attributes
if attrs.get("device_class") != "battery" or "battery_type" not in attrs:
attrs: dict[str, Any] = state.attributes
if attrs.get("device_class") != "battery":
continue
if "battery_type" not in attrs:
# Battery Notes 3.7.2: unavailable while its source is, and
# without attributes - rebuilt from the note's siblings.
rebuilt = _offline_note_attributes(hass, state, binary=binary_pass)
if rebuilt is None:
continue
attrs = rebuilt
if not binary_pass:
# EVERY matching percentage note counts as sibling coverage —
# kept, dropped or excluded: its low binary describes the same
@@ -985,6 +1069,7 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
level = None
available = state.state not in _NO_READING
low = bool(attrs.get("battery_low")) or str(state.state).lower() == "on"
latch_levelless.add(state.entity_id)
else:
level = _level_of(state.state)
available = state.state not in _NO_READING and level is not None
@@ -1005,7 +1090,7 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
# Offline AND not low AND no date = pure connectivity noise → drop —
# unless the user explicitly included THIS entity (#135): a stated
# battery stays visible even while it reads like noise.
if not available and not low and last_replaced is None and state.entity_id not in included:
if not available and not low and last_replaced is None and state.entity_id not in included and state.entity_id not in latched_now:
continue
# B3: only a KEPT note covers its source/device — a dropped dead note
# must not suppress the native fallback for its own device (a device
@@ -1048,6 +1133,7 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
available=available,
source="battery_notes",
low_threshold=_note_low_threshold(attrs, floor),
note_source=str(attrs.get("source_entity_id") or ""),
)
)
@@ -1280,7 +1366,7 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
# #180: hold level-driven rows low until they clearly recover (or a
# replacement is recorded) — AFTER every pass, so the sensor, the panel
# overview and the Replaced action all see the same ``low``.
_apply_low_latch(hass, out, latch_eligible)
_apply_low_latch(hass, out, latch_eligible, latch_levelless)
for bat in out:
if bat.source == "battery_notes":
bat.can_mark_replaced = has_replaced_button(hass, bat.entity_id)
@@ -484,7 +484,11 @@ async def async_record_replacement(hass: HomeAssistant, entity_id: str, replaced
if bat is None:
raise HomeAssistantError("not_found")
reg = er.async_get(hass).async_get(entity_id)
if reg is None or not reg.device_id:
# An entity note is reached through its source entity: by device, Battery
# Notes (3.7.2) prefers the device's own note, and the date would land on
# another battery of the same device.
target = {"source_entity_id": bat.note_source} if bat.note_source else {"device_id": reg.device_id} if reg and reg.device_id else None
if target is None:
raise HomeAssistantError("invalid_device")
if not hass.services.has_service("battery_notes", "set_battery_replaced"):
raise HomeAssistantError("not_available")
@@ -500,7 +504,7 @@ async def async_record_replacement(hass: HomeAssistant, entity_id: str, replaced
await hass.services.async_call(
"battery_notes",
"set_battery_replaced",
{"device_id": reg.device_id, "datetime_replaced": parsed.isoformat()},
{**target, "datetime_replaced": parsed.isoformat()},
blocking=False,
)
release_low_latch(hass, [entity_id])
@@ -124,27 +124,33 @@ def days_until(due: datetime, now: datetime) -> float:
return round((due - now).total_seconds() / 86400, 3)
def _add_months(anchor: date, months: int) -> date:
"""Advance ``anchor`` by ``months`` calendar months, clamping the day."""
def _add_months(anchor: date, months: int, day: int | None = None) -> date:
"""Advance ``anchor`` by ``months`` calendar months, clamping the day.
``day`` aims at another day of month than the anchor's own (a series on
the 31st whose anchor is a clamped 28 February), clamped the same way.
"""
total = anchor.month - 1 + months
year = anchor.year + total // 12
month = total % 12 + 1
last_day = calendar.monthrange(year, month)[1]
return date(year, month, min(anchor.day, last_day))
return date(year, month, min(day or anchor.day, last_day))
def add_interval(anchor: date, n: int, unit: str = "days") -> date:
def add_interval(anchor: date, n: int, unit: str = "days", *, day: int | None = None) -> date:
"""Return ``anchor`` advanced by ``n`` of ``unit``.
``unit`` is one of days / weeks / months / years; anything else (or None)
falls back to days, which keeps existing day-based tasks working unchanged.
``day`` (months / years only) is the day of month to land on, clamped to
the target month's length; the anchor's own day by default.
"""
if unit == "weeks":
return anchor + timedelta(weeks=n)
if unit == "months":
return _add_months(anchor, n)
return _add_months(anchor, n, day)
if unit == "years":
return _add_months(anchor, n * 12)
return _add_months(anchor, n * 12, day)
return anchor + timedelta(days=n)
@@ -78,6 +78,7 @@ def clear_cycle_modifiers(task_state: dict[str, Any]) -> None:
both must go when a task is re-anchored.
"""
task_state.pop("last_planned_due", None)
task_state.pop("planned_day", None)
task_state.pop("due_override", None)
@@ -121,6 +121,7 @@ _FRESH_COPY_STRIP_KEYS = (
"history",
"last_performed",
"last_planned_due",
"planned_day",
"due_override",
"adaptive_config",
"archived_at",
@@ -24,6 +24,7 @@ so the engine stays pure and sync.
from __future__ import annotations
import calendar
import logging
import statistics
from collections.abc import Callable, Mapping, Sequence
@@ -318,6 +319,7 @@ class Schedule:
today: date,
times_performed: int = 0,
due_override: date | None = None,
planned_day: int | None = None,
) -> date | None:
"""The next due date, or None for manual / archived one-time tasks.
@@ -341,6 +343,7 @@ class Schedule:
today=today,
times_performed=times_performed,
due_override=due_override,
planned_day=planned_day,
)
except (OverflowError, ValueError):
return None
@@ -354,6 +357,7 @@ class Schedule:
today: date,
times_performed: int,
due_override: date | None,
planned_day: int | None = None,
) -> date | None:
""":meth:`next_due` without the end-of-calendar guard."""
# Finite series exhausted by completion count → terminally done. Checked
@@ -375,6 +379,7 @@ class Schedule:
created_at=created_at,
last_planned_due=last_planned_due,
today=today,
planned_day=planned_day,
)
if self.kind == KIND_ONE_TIME:
return result
@@ -450,6 +455,7 @@ class Schedule:
created_at: date | None,
last_planned_due: date | None,
today: date,
planned_day: int | None = None,
) -> date | None:
"""The raw next due date before the seasonal window is applied."""
if self.kind == KIND_ONE_TIME:
@@ -488,22 +494,38 @@ class Schedule:
periods = 1 if days_gap < 0 else (days_gap // step) + 1
return anchor + timedelta(days=periods * step)
# Calendar units (months/years): step until past last_performed.
# Multiply from the ORIGINAL anchor instead of iterating on the
# clamped result — iterative adds let February permanently drag a
# day-31 anchor to day 28 (Jan 31 → Feb 28 → Mar 28 …); n×interval
# from the anchor recovers the intended day per target month
# (Jan 31 + 2 months = Mar 31). The clamp can still stick across
# CYCLES when a February due itself becomes the next anchor — the
# day_of_month kind is the tool for hard month-end pinning.
# Multiply from the anchor instead of iterating on the clamped
# result (Jan 31 + 2 months = Mar 31, not Mar 28). Across cycles a
# clamped February due becomes the next anchor; ``planned_day``
# (see carry_planned_day) remembers the day it stands for, so the
# series goes back to the 31st instead of staying on the 28th.
day = planned_day if last_planned_due is not None and planned_day and planned_day > anchor.day else None
candidate = anchor
for n in range(1, _MAX_PLANNED_STEPS + 1):
candidate = add_interval(anchor, n * every, self.unit)
candidate = add_interval(anchor, n * every, self.unit, day=day)
if candidate > last_performed:
return candidate
return candidate
return add_interval(last_performed, every, self.unit)
def carry_planned_day(self, intended: int | None, grid: date) -> int | None:
"""The day of month a new planned anchor stands for, or None when it is
the anchor's own day.
``grid`` is the occurrence just completed or skipped, ``intended`` the
day the series was on before it. A monthly or yearly series on the
29th-31st lands on a short month's last day; without this the clamped
date became the anchor and the series stayed on the 28th for good.
"""
if self.kind != KIND_INTERVAL or self.anchor != "planned" or self.unit not in ("months", "years"):
return None
if intended is None or not 1 <= intended <= 31 or intended <= grid.day:
return None
if grid.day != calendar.monthrange(grid.year, grid.month)[1]:
return None # not a clamped month end: the grid's own day is meant
return intended
def _calendar_occurrence(self, ref: date, *, inclusive: bool) -> date | None:
"""Next EFFECTIVE occurrence of a calendar kind on/after ``ref``.
@@ -724,6 +746,7 @@ def preview_occurrences(
series_ended = False
lp = last_performed
lpd: date | None = None
pday: int | None = None
times = times_performed
for _ in range(count):
nxt = schedule.next_due(
@@ -732,6 +755,7 @@ def preview_occurrences(
last_planned_due=lpd,
today=today,
times_performed=times,
planned_day=pday,
)
if nxt is None:
# A calendar entity that merely ran out of KNOWN events has not
@@ -741,6 +765,7 @@ def preview_occurrences(
if occurrences and nxt <= occurrences[-1]: # pragma: no cover
break # safety net: the engine must advance — never loop
occurrences.append(nxt)
pday = schedule.carry_planned_day(pday or (lpd or lp or today).day, nxt)
lp = nxt
lpd = nxt
times += 1
@@ -12,6 +12,7 @@ from __future__ import annotations
import itertools
import logging
import math
from collections.abc import Awaitable
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any
@@ -42,6 +43,8 @@ from .trigger_fallback import counter_baseline
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from .prediction_cache import PredictionCache
_LOGGER = logging.getLogger(__name__)
_SECONDS_PER_DAY = 86400.0
@@ -114,10 +117,16 @@ class SensorPredictor:
Async — requires HA recorder access for statistics_during_period.
Only applicable to sensor_based tasks with threshold/counter triggers.
``cache`` (the coordinator's) keeps the recorder-derived parts for a
statistics hour; without it every call queries the recorder. ``stale_ok``
lets a refresh that answers a click use the previous hour's values.
"""
def __init__(self, hass: HomeAssistant) -> None:
def __init__(self, hass: HomeAssistant, cache: PredictionCache | None = None, *, stale_ok: bool = False) -> None:
self.hass = hass
self._cache = cache
self._stale_ok = stale_ok
# ------------------------------------------------------------------
# Public entry point
@@ -197,16 +206,30 @@ class SensorPredictor:
# instead of fitting one line across the jumps (which reported a
# refill as a rising trend — the ET-4800 ink finding).
boundary = self._cycle_boundary_direction(trigger_config)
if boundary is not None:
points = await self._async_fetch_statistics_points(entity_id, DEGRADATION_CYCLE_LOOKBACK_DAYS)
degradation = self._compute_cycle_aware_degradation(entity_id, boundary, points)
else:
degradation = await self._async_compute_degradation(
async def _degradation() -> DegradationAnalysis:
if boundary is not None:
points = await self._async_fetch_statistics_points(entity_id, DEGRADATION_CYCLE_LOOKBACK_DAYS)
return self._compute_cycle_aware_degradation(entity_id, boundary, points)
return await self._async_compute_degradation(
entity_id,
attribute,
DEFAULT_DEGRADATION_LOOKBACK_DAYS,
)
# Only the recorder series feeds it, so the fit is cached per sensor
# and cycle direction; thresholds and baseline are applied below on
# every call. No series at all (current_value None) is not cached.
if self._cache is not None:
degradation = await self._cache.async_get(
("degradation", entity_id, boundary),
_degradation,
keep=lambda d: d.current_value is not None,
stale_ok=self._stale_ok,
)
else:
degradation = await _degradation()
# 2. Threshold prediction (synchronous math)
threshold_prediction = None
if degradation and degradation.slope_per_day is not None:
@@ -611,8 +634,15 @@ class SensorPredictor:
3. Compute Pearson correlation between env_value and actual_interval
4. If |correlation| > threshold, compute adjustment factor
"""
# Fetch environmental stats
env_points = await self._async_fetch_statistics_points(env_entity_id, DEFAULT_ENVIRONMENTAL_LOOKBACK_DAYS)
# Fetch environmental stats (cached per statistics hour; the live value
# and the completions below are read fresh on every call)
def _env_series() -> Awaitable[list[tuple[float, float]]]:
return self._async_fetch_statistics_points(env_entity_id, DEFAULT_ENVIRONMENTAL_LOOKBACK_DAYS)
if self._cache is not None:
env_points = await self._cache.async_get(("environment", env_entity_id), _env_series, stale_ok=self._stale_ok)
else:
env_points = await _env_series()
# Get current environmental value
current_env: float | None = None
@@ -257,6 +257,7 @@ def task_next_due(task: MaintenanceTask, today: date) -> date | None:
today=today,
times_performed=task.times_performed,
due_override=parse_iso_date(task.due_override),
planned_day=task.planned_day,
)