251 files
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user