278 files
This commit is contained in:
1 parent
d7a0a08372
commit
1b4f5f68c6
278 files changed
+36892
-7150
No files matched your search
@@ -16,6 +16,7 @@ from ..const import CONF_OBJECT, CONF_TASKS, DOMAIN, GLOBAL_UNIQUE_ID, Maintenan
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import MaintenanceSupporterData
|
||||
from ..storage import MaintenanceStore
|
||||
|
||||
# The status buckets we count. `ok` is included so the dashboard strategy
|
||||
# headline and a future summary sensor have a single source for it too.
|
||||
@@ -27,6 +28,17 @@ _COUNTED_STATUSES = (
|
||||
)
|
||||
|
||||
|
||||
def is_object_entry(entry: ConfigEntry | None) -> bool:
|
||||
"""True iff *entry* is one of OUR object entries (not None, not another
|
||||
domain's, not the global settings entry).
|
||||
|
||||
The three-part guard was hand-typed at eight sites (WS resolver, the two
|
||||
adopt handlers, the service-facing task helpers, the foreign-part
|
||||
resolver, …) — one predicate so a future site can't forget a leg.
|
||||
"""
|
||||
return entry is not None and entry.domain == DOMAIN and entry.unique_id != GLOBAL_UNIQUE_ID
|
||||
|
||||
|
||||
def object_name(entry: ConfigEntry) -> str:
|
||||
"""The object's display name, falling back to the entry title.
|
||||
|
||||
@@ -77,6 +89,23 @@ def get_runtime_data(hass: HomeAssistant, entry_id: str) -> MaintenanceSupporter
|
||||
return getattr(entry, "runtime_data", None)
|
||||
|
||||
|
||||
def get_store(hass: HomeAssistant, entry_id: str) -> MaintenanceStore | None:
|
||||
"""The entry's dynamic-state Store, or None when the entry is unknown or
|
||||
not loaded (disabled / setup-retry / mid-reload = no runtime_data).
|
||||
|
||||
``store = getattr(rd, "store", None) if rd else None`` was copied at nine
|
||||
WS sites — this is the one spelling."""
|
||||
rd = get_runtime_data(hass, entry_id)
|
||||
return getattr(rd, "store", None) if rd else None
|
||||
|
||||
|
||||
def get_coordinator_data(hass: HomeAssistant, entry_id: str) -> dict[str, Any] | None:
|
||||
"""The entry coordinator's live data, or None when it is not loaded."""
|
||||
rd = get_runtime_data(hass, entry_id)
|
||||
coordinator = getattr(rd, "coordinator", None) if rd else None
|
||||
return coordinator.data if coordinator is not None else None
|
||||
|
||||
|
||||
def compute_status_counts(hass: HomeAssistant) -> dict[str, Any]:
|
||||
"""Aggregate task status counts across every maintenance object.
|
||||
|
||||
@@ -96,8 +125,7 @@ def compute_status_counts(hass: HomeAssistant) -> dict[str, Any]:
|
||||
# count. Their cost still counts — budget is retained on archive.
|
||||
total_tasks += sum(1 for td in entry.data.get(CONF_TASKS, {}).values() if td.get("archived_at") is None)
|
||||
|
||||
rd = get_runtime_data(hass, entry.entry_id)
|
||||
coord_data = rd.coordinator.data if rd and rd.coordinator else None
|
||||
coord_data = get_coordinator_data(hass, entry.entry_id)
|
||||
for task in (coord_data or {}).get(CONF_TASKS, {}).values():
|
||||
status = str(task.get("_status", MaintenanceStatus.OK))
|
||||
if status in counts:
|
||||
|
||||
@@ -32,6 +32,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import re
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, timedelta
|
||||
from typing import Any, cast
|
||||
@@ -41,26 +42,19 @@ from homeassistant.util import dt as dt_util
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Editorial typical service life per battery type, in MONTHS — the forecast
|
||||
# anchor (battery_last_replaced + lifetime = predicted replacement). These are
|
||||
# deliberately conservative sensor-use estimates and are meant to be tunable;
|
||||
# unknown types fall back to DEFAULT_LIFETIME_MONTHS.
|
||||
TYPICAL_LIFETIME_MONTHS: dict[str, int] = {
|
||||
"AAAA": 10,
|
||||
"AAA": 10,
|
||||
"AA": 12,
|
||||
"C": 18,
|
||||
"D": 24,
|
||||
"9V": 12,
|
||||
"CR2": 18,
|
||||
"CR123A": 18,
|
||||
"CR2032": 18,
|
||||
"CR2450": 24,
|
||||
"CR2477": 24,
|
||||
"CR2016": 18,
|
||||
"CR2025": 18,
|
||||
}
|
||||
DEFAULT_LIFETIME_MONTHS = 12
|
||||
# The typical-lifetime table, the household overrides and the learned values
|
||||
# live in battery_lifetime (D#162 follow-up); the names stay importable here.
|
||||
from .battery_lifetime import (
|
||||
TYPICAL_LIFETIME_MONTHS,
|
||||
LifetimeInfo,
|
||||
canonical_type,
|
||||
has_type_forecast,
|
||||
is_rechargeable_type,
|
||||
is_shoppable_type,
|
||||
lifetime_resolver,
|
||||
observe_replacements,
|
||||
table_lifetime_months,
|
||||
)
|
||||
|
||||
# How far ahead "needed soon" looks by default (days).
|
||||
DEFAULT_HORIZON_DAYS = 28
|
||||
@@ -152,33 +146,12 @@ def _native_snapshot_cache(hass: HomeAssistant) -> dict[str, dict[str, Any]]:
|
||||
return cache
|
||||
|
||||
|
||||
def _norm_type(raw: Any) -> str:
|
||||
"""Canonicalize a battery-type label for grouping (upper, trimmed)."""
|
||||
s = str(raw or "").strip()
|
||||
return s.upper() if s else "UNKNOWN"
|
||||
|
||||
|
||||
# Battery Notes' library labels rechargeable packs with type strings like
|
||||
# "Rechargeable", "Nuki Battery Pack" or li-ion cell names. Such a battery is
|
||||
# CHARGED, never bought — so it must not enter the shopping groupings, and the
|
||||
# type-lifetime table (a primary-cell prior) has nothing honest to say about
|
||||
# it. Low tracking and the discharge-trend forecast stay: "charge the lock in
|
||||
# ~20 days" is exactly what the roster is for.
|
||||
_RECHARGEABLE_TYPE_RE = re.compile(
|
||||
r"rechargeable|akku|accu|li[- ]?ion|li[- ]?po|lifepo|ni[- ]?mh|nicd|18650|21700|"
|
||||
r"power ?pack|battery ?pack|built[- ]?in",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def is_rechargeable_type(battery_type: Any) -> bool:
|
||||
"""Whether a battery-type label describes a rechargeable pack/cell."""
|
||||
return bool(_RECHARGEABLE_TYPE_RE.search(str(battery_type or "")))
|
||||
|
||||
|
||||
def lifetime_months(battery_type: str) -> int:
|
||||
"""Typical service life for a (canonicalized) battery type."""
|
||||
return TYPICAL_LIFETIME_MONTHS.get(_norm_type(battery_type), DEFAULT_LIFETIME_MONTHS)
|
||||
"""Typical service life for a battery type — the built-in table value
|
||||
(aliases such as LR6/PP3/CR123 folded onto the table key). The forecast
|
||||
itself goes through :func:`battery_lifetime.lifetime_resolver`, which
|
||||
puts the household's override and the learned value in front of it."""
|
||||
return table_lifetime_months(battery_type)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -210,6 +183,11 @@ class Battery:
|
||||
# read_batteries for EVERY Battery Notes row (a low-only binary row has
|
||||
# no level either and needs it just as much as a sensorless one).
|
||||
can_mark_replaced: bool = False
|
||||
# D#162 follow-up: "manufacturer|model" of the device (device registry)
|
||||
# — the pool a learned lifetime is drawn from. A CR2032 in a door sensor
|
||||
# and one in a thermostat share nothing but the cell; devices of the same
|
||||
# model do. Empty when the device is unknown.
|
||||
model_key: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -237,10 +215,11 @@ class BatteryOverview:
|
||||
return len(self.low)
|
||||
|
||||
|
||||
def _predicted_date(bat: Battery) -> date | None:
|
||||
def _predicted_date(bat: Battery, months: int | None = None) -> date | None:
|
||||
if bat.last_replaced is None:
|
||||
return None
|
||||
months = lifetime_months(bat.battery_type)
|
||||
if months is None:
|
||||
months = lifetime_months(bat.battery_type)
|
||||
# Month arithmetic without dateutil: add whole months, clamp the day.
|
||||
y, m = bat.last_replaced.year, bat.last_replaced.month + months
|
||||
y += (m - 1) // 12
|
||||
@@ -255,9 +234,15 @@ def build_overview(
|
||||
today: date,
|
||||
horizon_days: int = DEFAULT_HORIZON_DAYS,
|
||||
trend_predictions: dict[str, tuple[int, str]] | None = None,
|
||||
lifetime_for: Callable[[Battery], LifetimeInfo] | None = None,
|
||||
) -> BatteryOverview:
|
||||
"""Aggregate batteries into the fleet view.
|
||||
|
||||
``lifetime_for`` resolves a battery's lifetime (override > this device's
|
||||
own replacements > devices of the same model > table > default, see
|
||||
battery_lifetime); without it the table alone is used — the pure-function
|
||||
tests and the summary sensors call it that way.
|
||||
|
||||
* ``low`` = reported low right now (Battery Notes' own threshold) — or a
|
||||
sensorless note whose forecast has passed with the due-without-sensor
|
||||
option on (D#162; decided in :func:`read_batteries`).
|
||||
@@ -274,9 +259,17 @@ def build_overview(
|
||||
types_seen: OrderedDict[str, None] = OrderedDict()
|
||||
|
||||
for bat in sorted(batteries, key=lambda b: b.device_name.lower()):
|
||||
t = _norm_type(bat.battery_type)
|
||||
# ONE normalisation for grouping, part ids and the lifetime table
|
||||
# (DRY audit 2026-09): before, grouping only upper-cased, so an LR6
|
||||
# note minted its own part/chip while the forecast read it as AA.
|
||||
t = canonical_type(bat.battery_type)
|
||||
types_seen[t] = None
|
||||
rechargeable = is_rechargeable_type(bat.battery_type)
|
||||
# The shopping groupings list only types a part can stand behind —
|
||||
# the same predicate discover_battery_types mints parts by (a low
|
||||
# "UNKNOWN" / "Manual" battery used to land in needs_now with no
|
||||
# part to buy).
|
||||
shoppable = is_shoppable_type(bat.battery_type)
|
||||
# Blend (#114 follow-up): the DISCHARGE TREND wins where the recorder
|
||||
# data supports it (medium/high confidence, filtered upstream) — it is
|
||||
# device-specific and usage-aware; the type's typical lifetime is the
|
||||
@@ -286,11 +279,14 @@ def build_overview(
|
||||
# "replace the vacuum's pack" dated from the day the device was added),
|
||||
# so they get a ~date only when the trend has earned one.
|
||||
trend = (trend_predictions or {}).get(bat.entity_id)
|
||||
info = lifetime_for(bat) if lifetime_for else LifetimeInfo(lifetime_months(bat.battery_type), "table")
|
||||
if trend is not None:
|
||||
days_raw: int | None = trend[0]
|
||||
source, confidence = "trend", trend[1]
|
||||
else:
|
||||
pred = None if rechargeable else _predicted_date(bat)
|
||||
# No type lifetime for rechargeables (the table describes primary
|
||||
# cells) nor for "Manual" / "Irreplaceable" / "Solar" notes.
|
||||
pred = None if rechargeable or not has_type_forecast(bat.battery_type) else _predicted_date(bat, info.months)
|
||||
days_raw = (pred - today).days if pred is not None else None
|
||||
source, confidence = "typical", None
|
||||
# B1 (decided 2026-08): a PASSED prediction while the battery still
|
||||
@@ -308,21 +304,21 @@ 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)
|
||||
low_row = _row(bat, t, days_raw, source, confidence, rechargeable=rechargeable, forecast_overdue=overdue, lifetime=info)
|
||||
else:
|
||||
low_row = _row(bat, t, None, rechargeable=rechargeable)
|
||||
low_row = _row(bat, t, None, rechargeable=rechargeable, lifetime=info)
|
||||
ov.low.append(low_row)
|
||||
if not rechargeable:
|
||||
if shoppable:
|
||||
ov.needs_now[t] = ov.needs_now.get(t, 0) + bat.quantity
|
||||
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))
|
||||
if not rechargeable:
|
||||
ov.soon.append(_row(bat, t, days, source, confidence, rechargeable=rechargeable, forecast_overdue=overdue, lifetime=info))
|
||||
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), "status": "soon"})
|
||||
ov.all.append({**_row(bat, t, days, source, confidence, rechargeable=rechargeable, forecast_overdue=overdue, lifetime=info), "status": "soon"})
|
||||
continue
|
||||
ov.all.append({**_row(bat, t, days, source, confidence, rechargeable=rechargeable), "status": "ok"})
|
||||
ov.all.append({**_row(bat, t, days, source, confidence, rechargeable=rechargeable, lifetime=info), "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)
|
||||
@@ -340,9 +336,17 @@ def _row(
|
||||
*,
|
||||
rechargeable: bool = False,
|
||||
forecast_overdue: bool = False,
|
||||
lifetime: LifetimeInfo | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"entity_id": bat.entity_id,
|
||||
# D#162 follow-up: which lifetime the "typical" forecast used and where
|
||||
# it came from — the roster tooltip says "18 months, learned from 5
|
||||
# replacements" instead of a bare date.
|
||||
"lifetime_months": lifetime.months if lifetime else None,
|
||||
"lifetime_source": lifetime.source if lifetime else None,
|
||||
"lifetime_samples": lifetime.samples if lifetime else 0,
|
||||
"model_key": bat.model_key,
|
||||
"device_name": bat.device_name,
|
||||
"battery_type": canon_type,
|
||||
"quantity": bat.quantity,
|
||||
@@ -371,13 +375,27 @@ def _row(
|
||||
}
|
||||
|
||||
|
||||
def _quantity_of(attrs: Any) -> int:
|
||||
"""``battery_quantity`` as a positive int; anything odd ("2 pcs", "1.0",
|
||||
None) is one cell - a template note with a bad value must not raise out
|
||||
of read_batteries and blank the whole fleet."""
|
||||
raw = attrs.get("battery_quantity") if hasattr(attrs, "get") else None
|
||||
try:
|
||||
qty = int(float(raw)) if raw not in (None, "") else 1
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
return qty if qty >= 1 else 1
|
||||
|
||||
|
||||
def _parse_last_replaced(raw: Any) -> date | None:
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
parsed = dt_util.parse_datetime(str(raw))
|
||||
if parsed is not None:
|
||||
return parsed.date()
|
||||
# An aware stamp (Battery Notes writes UTC) is a LOCAL calendar
|
||||
# date: 23:30 UTC on the 3rd is the 4th in Berlin.
|
||||
return (dt_util.as_local(parsed) if parsed.tzinfo is not None else parsed).date()
|
||||
return date.fromisoformat(str(raw)[:10])
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
@@ -454,6 +472,26 @@ def fleet_due_without_sensor(hass: HomeAssistant) -> bool:
|
||||
return _fleet_object(hass).get(BATTERY_FLEET_DUE_WITHOUT_SENSOR) is not False
|
||||
|
||||
|
||||
def device_model_key(hass: HomeAssistant, device_id: str | None) -> str:
|
||||
""""manufacturer|model" (lowercased) for a device registry id, "" when
|
||||
unknown — the learning pool key (see battery_lifetime)."""
|
||||
if not device_id:
|
||||
return ""
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
device = dr.async_get(hass).async_get(device_id)
|
||||
if device is None:
|
||||
return ""
|
||||
# HA 2026.9 may hand back a ChildDeviceEntry, which has no make/model.
|
||||
manufacturer = str(getattr(device, "manufacturer", None) or "").strip().lower()
|
||||
model = str(getattr(device, "model", None) or getattr(device, "model_id", None) or "").strip().lower()
|
||||
if not model or not manufacturer:
|
||||
# A generic model without a maker ("Door Sensor") would pool
|
||||
# unrelated devices from different integrations.
|
||||
return ""
|
||||
return f"{manufacturer}|{model}"
|
||||
|
||||
|
||||
def _is_self_charging(hass: HomeAssistant, device_id: str | None) -> bool:
|
||||
"""Whether a device recharges itself — its battery is never REPLACED.
|
||||
|
||||
@@ -675,7 +713,8 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
|
||||
entity_id=state.entity_id,
|
||||
device_name=attrs.get("device_name") or attrs.get("friendly_name") or state.entity_id,
|
||||
battery_type=str(attrs.get("battery_type") or "Unknown"),
|
||||
quantity=int(attrs.get("battery_quantity") or 1),
|
||||
quantity=_quantity_of(attrs),
|
||||
model_key=device_model_key(hass, dev_id),
|
||||
low=low,
|
||||
level=level,
|
||||
last_replaced=last_replaced,
|
||||
@@ -779,6 +818,7 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
|
||||
device_name=name or rec["eid"],
|
||||
battery_type="Rechargeable" if rec.get("self_charging") else "Unknown",
|
||||
quantity=1,
|
||||
model_key=device_model_key(hass, rec.get("device_id")),
|
||||
low=low,
|
||||
level=level,
|
||||
last_replaced=None,
|
||||
@@ -800,6 +840,11 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
|
||||
# reading (a plus of any kind, a native row) wins the device.
|
||||
due_without_sensor = fleet_due_without_sensor(hass)
|
||||
today = dt_util.now().date()
|
||||
# Bug audit 2026-09-12: the "due" decision below must use the SAME lifetime
|
||||
# the roster forecasts with (override > learned > table) - it used the bare
|
||||
# table, so an override or a learned value moved the roster's ~date while
|
||||
# the task kept firing (or never fired) on the table's.
|
||||
lifetime_for = lifetime_resolver(hass)
|
||||
native_devices = {rec["device_id"] for rec in native.values() if rec["device_id"]}
|
||||
for state in hass.states.async_all("sensor"):
|
||||
if not _is_type_note(state):
|
||||
@@ -853,7 +898,8 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
|
||||
entity_id=eid,
|
||||
device_name=name,
|
||||
battery_type=battery_type,
|
||||
quantity=int(attrs.get("battery_quantity") or 1),
|
||||
quantity=_quantity_of(attrs),
|
||||
model_key=device_model_key(hass, dev_id),
|
||||
low=False,
|
||||
level=None,
|
||||
last_replaced=last_replaced,
|
||||
@@ -864,8 +910,8 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
|
||||
)
|
||||
# Due = the forecast has PASSED (same arithmetic as build_overview's
|
||||
# forecast_overdue). Rechargeables never get a table forecast.
|
||||
if due_without_sensor and not is_rechargeable_type(battery_type):
|
||||
pred = _predicted_date(bat)
|
||||
if due_without_sensor and is_shoppable_type(battery_type):
|
||||
pred = _predicted_date(bat, lifetime_for(bat).months)
|
||||
bat.low = pred is not None and pred < today
|
||||
out.append(bat)
|
||||
for bat in out:
|
||||
@@ -973,7 +1019,9 @@ def compute_overview(hass: HomeAssistant, *, horizon_days: int = DEFAULT_HORIZON
|
||||
:func:`async_compute_overview` instead.
|
||||
"""
|
||||
today = dt_util.now().date()
|
||||
return build_overview(read_batteries(hass), today=today, horizon_days=horizon_days)
|
||||
batteries = read_batteries(hass)
|
||||
observe_replacements(hass, batteries)
|
||||
return build_overview(batteries, today=today, horizon_days=horizon_days, lifetime_for=lifetime_resolver(hass))
|
||||
|
||||
|
||||
# ── discharge-trend forecast (#114 follow-up) ───────────────────────────────
|
||||
@@ -1048,8 +1096,11 @@ async def async_trend_predictions(hass: HomeAssistant, batteries: list[Battery])
|
||||
async def async_compute_overview(hass: HomeAssistant, *, horizon_days: int = DEFAULT_HORIZON_DAYS) -> BatteryOverview:
|
||||
"""Read + trend-enrich + aggregate (the panel's entry point)."""
|
||||
batteries = read_batteries(hass)
|
||||
observe_replacements(hass, batteries)
|
||||
trends = await async_trend_predictions(hass, batteries)
|
||||
return build_overview(batteries, today=dt_util.now().date(), horizon_days=horizon_days, trend_predictions=trends)
|
||||
return build_overview(
|
||||
batteries, today=dt_util.now().date(), horizon_days=horizon_days, trend_predictions=trends, lifetime_for=lifetime_resolver(hass)
|
||||
)
|
||||
|
||||
|
||||
# ── level history for the roster sparklines ────────────────────────────────
|
||||
@@ -1188,11 +1239,12 @@ def discover_battery_types(hass: HomeAssistant) -> OrderedDict[str, int]:
|
||||
"""
|
||||
totals: OrderedDict[str, int] = OrderedDict()
|
||||
for bat in read_batteries(hass):
|
||||
if is_rechargeable_type(bat.battery_type):
|
||||
continue
|
||||
t = _norm_type(bat.battery_type)
|
||||
if t == "UNKNOWN":
|
||||
# "Irreplaceable" / "Manual" / "Solar" describe the device, not a
|
||||
# cell anyone stocks - no part, no reorder threshold. Same predicate
|
||||
# as the overview's shopping groupings (is_shoppable_type).
|
||||
if not is_shoppable_type(bat.battery_type):
|
||||
continue
|
||||
t = canonical_type(bat.battery_type)
|
||||
totals[t] = totals.get(t, 0) + bat.quantity
|
||||
return OrderedDict(sorted(totals.items()))
|
||||
|
||||
@@ -1208,6 +1260,7 @@ __all__ = [
|
||||
"async_trend_predictions",
|
||||
"battery_notes_summary",
|
||||
"build_overview",
|
||||
"canonical_type",
|
||||
"compute_overview",
|
||||
"discover_battery_types",
|
||||
"fleet_due_without_sensor",
|
||||
|
||||
@@ -32,8 +32,10 @@ from ..const import (
|
||||
CONF_TASK_CONSUMES_PARTS,
|
||||
CONF_TASKS,
|
||||
DOMAIN,
|
||||
GLOBAL_UNIQUE_ID,
|
||||
)
|
||||
from .battery_fleet import _norm_type, discover_battery_types, lifetime_months, note_sibling_entity, read_batteries
|
||||
from .battery_fleet import canonical_type, discover_battery_types, lifetime_months, note_sibling_entity, read_batteries
|
||||
from .global_options import get_default_warning_days
|
||||
from .trigger_fallback import threshold_limits_overlap
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -80,6 +82,9 @@ async def async_setup_battery_fleet(hass: HomeAssistant, language: str | None =
|
||||
|
||||
existing = find_fleet_entry(hass)
|
||||
if existing is not None:
|
||||
# Fold alias-typed parts (batt_lr6 → batt_aa) BEFORE the reconcile
|
||||
# would mint the canonical twin next to them.
|
||||
migrated = await migrate_fleet_part_ids(hass, existing)
|
||||
# An explicit setup asks for the full type-part set again — forget
|
||||
# the parts deleted since (the start-up reconcile honours them).
|
||||
if existing.data.get(CONF_OBJECT, {}).get(BATTERY_FLEET_REMOVED_PARTS):
|
||||
@@ -98,11 +103,17 @@ async def async_setup_battery_fleet(hass: HomeAssistant, language: str | None =
|
||||
store.set_part_stock(pid, 0)
|
||||
await store.async_save()
|
||||
repaired = await _reconcile_fleet_task(hass, existing, lang)
|
||||
if migrated and not repaired:
|
||||
# The live stock sensors still carry the old part ids — reload
|
||||
# once so they come back under the canonical ones (the task
|
||||
# repair above reloads on its own).
|
||||
await hass.config_entries.async_reload(existing.entry_id)
|
||||
return {
|
||||
"entry_id": existing.entry_id,
|
||||
"created": False,
|
||||
"types": list(types),
|
||||
"parts_added": len(added_pids),
|
||||
"parts_migrated": migrated,
|
||||
"task_repaired": repaired,
|
||||
}
|
||||
|
||||
@@ -134,7 +145,7 @@ async def async_setup_battery_fleet(hass: HomeAssistant, language: str | None =
|
||||
await store.async_save()
|
||||
|
||||
# The single aggregate task, triggered by the global low-count sensor.
|
||||
task = _fleet_task(obj.get("id", ""), lang)
|
||||
task = _fleet_task(obj.get("id", ""), lang, warning_days=get_default_warning_days(hass))
|
||||
await async_persist_task(hass, entry, task)
|
||||
|
||||
return {
|
||||
@@ -164,8 +175,11 @@ def _fleet_trigger_config() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _fleet_task(obj_id: str, lang: str) -> dict[str, Any]:
|
||||
"""The single aggregate fleet task, with localized name + notes."""
|
||||
def _fleet_task(obj_id: str, lang: str, *, warning_days: int) -> dict[str, Any]:
|
||||
"""The single aggregate fleet task, with localized name + notes.
|
||||
|
||||
``warning_days`` is the household default like every other task
|
||||
creator writes (it was the one task record without it)."""
|
||||
from ..templates import localize_template_text
|
||||
|
||||
return {
|
||||
@@ -176,6 +190,7 @@ def _fleet_task(obj_id: str, lang: str) -> dict[str, Any]:
|
||||
"enabled": True,
|
||||
TASK_FLAG: True,
|
||||
"schedule": {"kind": "manual"},
|
||||
"warning_days": warning_days,
|
||||
"trigger_config": _fleet_trigger_config(),
|
||||
"created_at": dt_util.now().date().isoformat(),
|
||||
"notes": localize_template_text(
|
||||
@@ -357,10 +372,13 @@ async def async_mark_replaced(hass: HomeAssistant, entity_ids: list[str] | None
|
||||
# Renamed entity ids break the naming contract — the registry
|
||||
# still knows the note's button (D#162 rows depend on it).
|
||||
button = note_sibling_entity(hass, eid, domain="button", uid_suffix="_battery_replaced_button") or button
|
||||
if hass.states.get(button) is not None:
|
||||
await hass.services.async_call("button", "press", {"entity_id": button}, blocking=False)
|
||||
pressed += 1
|
||||
t = _norm_type(bat.battery_type)
|
||||
if hass.states.get(button) is None:
|
||||
# Nothing to press → nothing was replaced: no date is written, so
|
||||
# consuming stock here would double-charge the next real press.
|
||||
continue
|
||||
await hass.services.async_call("button", "press", {"entity_id": button}, blocking=False)
|
||||
pressed += 1
|
||||
t = canonical_type(bat.battery_type)
|
||||
by_type[t] = by_type.get(t, 0) + bat.quantity
|
||||
|
||||
consumed: dict[str, int] = {}
|
||||
@@ -375,7 +393,7 @@ async def async_mark_replaced(hass: HomeAssistant, entity_ids: list[str] | None
|
||||
await async_change_part_stock(hass, fleet, pid, delta=-qty)
|
||||
consumed[pid] = qty
|
||||
|
||||
return {"marked": len(targets), "pressed": pressed, "consumed": consumed}
|
||||
return {"marked": pressed, "pressed": pressed, "consumed": consumed}
|
||||
|
||||
|
||||
def _mutate_fleet_object(hass: HomeAssistant, mutate: Callable[[dict[str, Any]], None]) -> bool:
|
||||
@@ -519,7 +537,7 @@ async def _reconcile_fleet_task(hass: HomeAssistant, entry: ConfigEntry, lang: s
|
||||
return True
|
||||
|
||||
obj = entry.data.get(CONF_OBJECT, {})
|
||||
await async_persist_task(hass, entry, _fleet_task(obj.get("id", ""), lang))
|
||||
await async_persist_task(hass, entry, _fleet_task(obj.get("id", ""), lang, warning_days=get_default_warning_days(hass)))
|
||||
return True
|
||||
|
||||
|
||||
@@ -596,6 +614,10 @@ async def reconcile_fleet_parts_at_start(hass: HomeAssistant, entry: ConfigEntry
|
||||
trigger_healed = _heal_fleet_trigger_recovery_flag(hass, entry)
|
||||
_warn_fleet_trigger_overlap(entry)
|
||||
|
||||
# Alias-typed parts first (batt_lr6 → batt_aa): the reconcile below mints
|
||||
# parts by CANONICAL type and would otherwise add the twin next to them.
|
||||
migrated = await migrate_fleet_part_ids(hass, entry)
|
||||
|
||||
types = discover_battery_types(hass)
|
||||
added = _reconcile_type_parts(hass, entry, types, lang)
|
||||
|
||||
@@ -644,7 +666,268 @@ async def reconcile_fleet_parts_at_start(hass: HomeAssistant, entry: ConfigEntry
|
||||
for pid in added:
|
||||
store.set_part_stock(pid, 0)
|
||||
await store.async_save()
|
||||
return {"added": added, "pruned": pruned, "orphans_removed": orphans_removed, "trigger_healed": trigger_healed}
|
||||
return {
|
||||
"added": added,
|
||||
"pruned": pruned,
|
||||
"orphans_removed": orphans_removed,
|
||||
"trigger_healed": trigger_healed,
|
||||
"migrated": migrated,
|
||||
}
|
||||
|
||||
|
||||
# ── canonical part ids (DRY audit 2026-09) ─────────────────────────────────
|
||||
#
|
||||
# Part ids are ``batt_<type>`` and the type used to be the label merely
|
||||
# upper-cased, while the lifetime table, the overrides and the learning
|
||||
# already folded aliases (LR6 → AA, PP3 → 9V, "AA Lithium" → AA). A note typed
|
||||
# "LR6" therefore minted its own part and shopping chip although the forecast
|
||||
# treated it as AA — and an AA override silently applied to the LR6 row.
|
||||
# Everything groups by ``canonical_type`` now; the ids already persisted are
|
||||
# folded once, here, with every reference following.
|
||||
|
||||
FLEET_PART_PREFIX = "batt_"
|
||||
|
||||
|
||||
def canonical_part_id(part_id: str) -> str | None:
|
||||
"""``batt_<label>`` → ``batt_<canonical label>``; None when the id is not
|
||||
a fleet type-part id or already canonical."""
|
||||
if not part_id.startswith(FLEET_PART_PREFIX):
|
||||
return None
|
||||
label = part_id[len(FLEET_PART_PREFIX) :]
|
||||
if not label:
|
||||
return None
|
||||
new_id = f"{FLEET_PART_PREFIX}{canonical_type(label).lower()}"
|
||||
return None if new_id == part_id else new_id
|
||||
|
||||
|
||||
_MERGE_TAKE_IF_EMPTY = ("mpn", "gtin", "vendor", "storage_location", "product_url", "cost", "doc_id")
|
||||
|
||||
|
||||
def _merge_parts(keep: dict[str, Any], gone: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Fold ``gone`` into ``keep`` (the canonical part wins on conflicts):
|
||||
the LOWER reorder threshold, the union of product/vendor/document
|
||||
fields, auto-buy if either had it, notes concatenated when they differ."""
|
||||
out = dict(keep)
|
||||
for key in _MERGE_TAKE_IF_EMPTY:
|
||||
if not out.get(key) and gone.get(key):
|
||||
out[key] = gone[key]
|
||||
for key in ("reorder_threshold", "restock_quantity"):
|
||||
a, b = out.get(key), gone.get(key)
|
||||
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
|
||||
out[key] = min(a, b)
|
||||
elif a is None and b is not None:
|
||||
out[key] = b
|
||||
out["auto_buy_task"] = bool(out.get("auto_buy_task")) or bool(gone.get("auto_buy_task"))
|
||||
gone_notes = str(gone.get("notes") or "")
|
||||
keep_notes = str(out.get("notes") or "")
|
||||
if gone_notes and gone_notes != keep_notes and _extract_placeholder(gone_notes, "Typical service life ~{months} months.", "months") is None:
|
||||
# The user's own note on the folded part — keep it (the seeded
|
||||
# "typical service life" line is dropped, the canonical part has one).
|
||||
out["notes"] = f"{keep_notes}\n{gone_notes}".strip() if keep_notes else gone_notes
|
||||
return out
|
||||
|
||||
|
||||
def _qty_sum(a: Any, b: Any) -> int | float:
|
||||
"""Sum two link quantities (default 1 each); stays an int when both are."""
|
||||
|
||||
def _num(x: Any) -> int | float:
|
||||
if isinstance(x, bool) or not isinstance(x, (int, float)):
|
||||
return 1
|
||||
return x
|
||||
|
||||
total = _num(a) + _num(b)
|
||||
return int(total) if float(total).is_integer() else total
|
||||
|
||||
|
||||
def _repoint_links(links: Any, moves: dict[str, str], *, owner_id: str, foreign: bool) -> tuple[list[Any], bool]:
|
||||
"""Re-point ``{part_id, quantity, entry_id?}`` links (task consumption
|
||||
AND history ``used_parts``) at the canonical ids, merging two links that
|
||||
now name the same part. ``foreign`` = the links live on ANOTHER object,
|
||||
so only pooled links carrying our ``entry_id`` are ours to touch."""
|
||||
if not isinstance(links, list):
|
||||
return links, False
|
||||
out: list[Any] = []
|
||||
by_key: dict[tuple[str | None, str], dict[str, Any]] = {}
|
||||
changed = False
|
||||
for link in links:
|
||||
if not isinstance(link, dict):
|
||||
out.append(link)
|
||||
continue
|
||||
pid = str(link.get("part_id") or "")
|
||||
owner = str(link.get("entry_id") or "").strip() or None
|
||||
ours = owner == owner_id if foreign else owner in (None, owner_id)
|
||||
if pid in moves and ours:
|
||||
link = {**link, "part_id": moves[pid]}
|
||||
pid = moves[pid]
|
||||
changed = True
|
||||
key = (owner, pid)
|
||||
twin = by_key.get(key)
|
||||
if twin is not None and pid:
|
||||
# Two links to what is now ONE part: fold the quantities.
|
||||
twin["quantity"] = _qty_sum(twin.get("quantity"), link.get("quantity"))
|
||||
changed = True
|
||||
continue
|
||||
link = dict(link)
|
||||
by_key[key] = link
|
||||
out.append(link)
|
||||
return out, changed
|
||||
|
||||
|
||||
def _repoint_store_history(store: Any, task_ids: list[str], moves: dict[str, str], *, owner_id: str, foreign: bool) -> int:
|
||||
"""``used_parts`` on history entries in ONE Store. Returns entries touched."""
|
||||
touched = 0
|
||||
for tid in task_ids:
|
||||
history = store.get_history(tid)
|
||||
new_history: list[dict[str, Any]] = []
|
||||
changed = False
|
||||
for entry in history:
|
||||
used = entry.get("used_parts") if isinstance(entry, dict) else None
|
||||
if used is None:
|
||||
new_history.append(entry)
|
||||
continue
|
||||
new_used, c = _repoint_links(used, moves, owner_id=owner_id, foreign=foreign)
|
||||
if c:
|
||||
entry = {**entry, "used_parts": new_used}
|
||||
changed = True
|
||||
touched += 1
|
||||
new_history.append(entry)
|
||||
if changed:
|
||||
store.set_history(tid, new_history)
|
||||
return touched
|
||||
|
||||
|
||||
def _repoint_tasks(tasks: dict[str, Any], moves: dict[str, str], *, owner_id: str, foreign: bool) -> tuple[dict[str, Any], bool]:
|
||||
"""Consumption links (and, on the fleet itself, the auto-buy ``part_ref``)."""
|
||||
from .parts import PART_REF_FIELD
|
||||
|
||||
out: dict[str, Any] = {}
|
||||
changed = False
|
||||
for tid, task in tasks.items():
|
||||
links, c = _repoint_links(task.get(CONF_TASK_CONSUMES_PARTS), moves, owner_id=owner_id, foreign=foreign)
|
||||
if c:
|
||||
task = {**task, CONF_TASK_CONSUMES_PARTS: links}
|
||||
changed = True
|
||||
ref = task.get(PART_REF_FIELD) if not foreign else None
|
||||
if isinstance(ref, dict) and str(ref.get("part_id") or "") in moves:
|
||||
task = {**task, PART_REF_FIELD: {**ref, "part_id": moves[str(ref["part_id"])]}}
|
||||
changed = True
|
||||
out[tid] = task
|
||||
return out, changed
|
||||
|
||||
|
||||
async def migrate_fleet_part_ids(hass: HomeAssistant, entry: ConfigEntry) -> dict[str, str]:
|
||||
"""Fold every ``batt_<alias>`` part onto ``batt_<canonical>`` — once.
|
||||
|
||||
Idempotent (a canonical id maps to itself and is skipped). For each
|
||||
part whose id is not canonical:
|
||||
|
||||
* the canonical part already exists → MERGE: stock summed, the lower
|
||||
reorder threshold, product/vendor/document fields unioned, auto-buy
|
||||
OR-ed, the user's own notes appended; the alias part is deleted.
|
||||
* otherwise → RENAME in place (the untouched seeded name "LR6 battery"
|
||||
becomes "AA battery").
|
||||
|
||||
In both cases every reference follows: consumption links on the fleet's
|
||||
own tasks and on other objects' tasks that pool from the fleet, the
|
||||
``part_ref`` of an auto-buy task, ``used_parts`` on history entries in
|
||||
every Store, the deleted-parts tombstones, the stock, and the stock
|
||||
sensor's registry entry (renamed so a customised entity id survives;
|
||||
removed when the canonical sensor exists). Returns ``{old: new}``.
|
||||
"""
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from ..const import STORES_CACHE_KEY
|
||||
|
||||
parts = dict(entry.data.get(CONF_PARTS) or {})
|
||||
moves: dict[str, str] = {}
|
||||
for pid in parts:
|
||||
new_id = canonical_part_id(str(pid))
|
||||
if new_id is not None:
|
||||
moves[str(pid)] = new_id
|
||||
|
||||
obj = dict(entry.data.get(CONF_OBJECT) or {})
|
||||
tombstones = [str(t) for t in (obj.get(BATTERY_FLEET_REMOVED_PARTS) or [])]
|
||||
live_after = (set(parts) - set(moves)) | set(moves.values())
|
||||
new_tombstones = sorted({canonical_part_id(t) or t for t in tombstones} - live_after)
|
||||
tombstones_changed = new_tombstones != sorted(set(tombstones))
|
||||
if not moves and not tombstones_changed:
|
||||
return {}
|
||||
|
||||
rd = getattr(entry, "runtime_data", None)
|
||||
store = getattr(rd, "store", None) if rd else None
|
||||
if store is None:
|
||||
store = hass.data.get(STORES_CACHE_KEY, {}).get(entry.entry_id)
|
||||
|
||||
# 1. The part definitions + stock.
|
||||
for old, new in moves.items():
|
||||
gone = dict(parts.pop(old))
|
||||
if new in parts:
|
||||
parts[new] = _merge_parts(parts[new], gone)
|
||||
_LOGGER.info("Battery fleet: merged part %s into %s (alias type)", old, new)
|
||||
else:
|
||||
gone["id"] = new
|
||||
name = str(gone.get("name") or "")
|
||||
label = _extract_placeholder(name, "{type} battery", "type")
|
||||
if label is not None and label.lower() == old[len(FLEET_PART_PREFIX) :]:
|
||||
gone["name"] = name.replace(label, canonical_type(label), 1)
|
||||
parts[new] = gone
|
||||
_LOGGER.info("Battery fleet: renamed part %s to %s (canonical type)", old, new)
|
||||
if store is not None:
|
||||
old_stock, new_stock = store.get_part_stock(old), store.get_part_stock(new)
|
||||
if old_stock is not None or new_stock is not None:
|
||||
store.set_part_stock(new, (old_stock or 0) + (new_stock or 0))
|
||||
store.remove_part(old)
|
||||
|
||||
# 2. The fleet's own tasks (links + auto-buy refs) and its history.
|
||||
tasks, _ = _repoint_tasks(dict(entry.data.get(CONF_TASKS) or {}), moves, owner_id=entry.entry_id, foreign=False)
|
||||
new_data = dict(entry.data)
|
||||
new_data[CONF_PARTS] = parts
|
||||
new_data[CONF_TASKS] = tasks
|
||||
if tombstones_changed:
|
||||
obj = dict(obj)
|
||||
if new_tombstones:
|
||||
obj[BATTERY_FLEET_REMOVED_PARTS] = new_tombstones
|
||||
else:
|
||||
obj.pop(BATTERY_FLEET_REMOVED_PARTS, None)
|
||||
new_data[CONF_OBJECT] = obj
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
if store is not None and moves:
|
||||
_repoint_store_history(store, list(tasks), moves, owner_id=entry.entry_id, foreign=False)
|
||||
await store.async_save()
|
||||
|
||||
if not moves:
|
||||
return {}
|
||||
|
||||
# 3. Other objects pooling from the fleet (links carry our entry_id).
|
||||
stores = hass.data.get(STORES_CACHE_KEY, {})
|
||||
for other in hass.config_entries.async_entries(DOMAIN):
|
||||
if other.entry_id == entry.entry_id or other.unique_id == GLOBAL_UNIQUE_ID:
|
||||
continue
|
||||
other_tasks = dict(other.data.get(CONF_TASKS) or {})
|
||||
if not other_tasks:
|
||||
continue
|
||||
repointed, changed = _repoint_tasks(other_tasks, moves, owner_id=entry.entry_id, foreign=True)
|
||||
if changed:
|
||||
hass.config_entries.async_update_entry(other, data={**other.data, CONF_TASKS: repointed})
|
||||
other_store = getattr(getattr(other, "runtime_data", None), "store", None) or stores.get(other.entry_id)
|
||||
if other_store is not None and _repoint_store_history(other_store, list(other_tasks), moves, owner_id=entry.entry_id, foreign=True):
|
||||
await other_store.async_save()
|
||||
|
||||
# 4. The stock sensors' registry entries (unique_id ends with _part_<id>).
|
||||
ent_reg = er.async_get(hass)
|
||||
for reg_entry in list(er.async_entries_for_config_entry(ent_reg, entry.entry_id)):
|
||||
uid = reg_entry.unique_id or ""
|
||||
for old, new in moves.items():
|
||||
suffix = f"_part_{old}"
|
||||
if not uid.endswith(suffix):
|
||||
continue
|
||||
new_uid = uid[: -len(old)] + new
|
||||
if ent_reg.async_get_entity_id(reg_entry.domain, reg_entry.platform, new_uid) is None:
|
||||
ent_reg.async_update_entity(reg_entry.entity_id, new_unique_id=new_uid)
|
||||
else:
|
||||
ent_reg.async_remove(reg_entry.entity_id)
|
||||
break
|
||||
return moves
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -20,8 +20,9 @@ from ..const import (
|
||||
MAX_CHECKLIST_ITEMS,
|
||||
)
|
||||
from .aggregate import merged_tasks
|
||||
from .dates import INTERVAL_UNITS
|
||||
from .dates import INTERVAL_UNITS, normalize_hhmm
|
||||
from .global_options import get_default_warning_days
|
||||
from .pause import write_anchor
|
||||
from .reading_slots import parse_reading_slots_text, reading_slots_text
|
||||
from .schedule import read_legacy_fields
|
||||
|
||||
@@ -283,10 +284,11 @@ def import_objects_csv(
|
||||
if anchor in ("planned", "completion"):
|
||||
task_data["interval_anchor"] = anchor
|
||||
|
||||
# schedule_time round-trip with strict HH:MM validation; malformed
|
||||
# values are dropped silently (consistent with other CSV import fields).
|
||||
sched_time = (row.get("schedule_time") or "").strip()
|
||||
if sched_time and re.fullmatch(r"^([01]\d|2[0-3]):[0-5]\d$", sched_time):
|
||||
# schedule_time round-trip, canonical HH:MM ("HH:MM:SS" from the
|
||||
# options flow folds); malformed values are dropped silently
|
||||
# (consistent with other CSV import fields).
|
||||
sched_time = normalize_hhmm(row.get("schedule_time"))
|
||||
if sched_time:
|
||||
task_data["schedule_time"] = sched_time
|
||||
|
||||
reading_unit = (row.get("reading_unit") or "").strip()
|
||||
@@ -300,7 +302,7 @@ def import_objects_csv(
|
||||
|
||||
last_performed = (row.get("last_performed") or "").strip()
|
||||
if last_performed:
|
||||
task_data["last_performed"] = last_performed
|
||||
write_anchor(task_data, last_performed)
|
||||
|
||||
notes = (row.get("notes") or "").strip()
|
||||
if notes:
|
||||
|
||||
@@ -10,13 +10,57 @@ from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from ..const import TIME_HHMMSS_PATTERN
|
||||
from .workday import is_business_day
|
||||
|
||||
INTERVAL_UNITS = ("days", "weeks", "months", "years")
|
||||
|
||||
|
||||
def parse_hhmm(value: Any) -> time | None:
|
||||
"""``"HH:MM"`` or ``"HH:MM:SS"`` (HA's TimeSelector) → ``time`` to the
|
||||
minute; ``None`` when absent or malformed (hours 0–23, minutes 0–59).
|
||||
|
||||
The one parser behind the status sensor, the calendar entity and the
|
||||
model's sub-day overdue refinement — three hand-copied ``split(":")``
|
||||
blocks before — and behind :func:`normalize_hhmm`.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
text = value.strip()
|
||||
if not TIME_HHMMSS_PATTERN.match(text):
|
||||
return None
|
||||
hours, minutes = text.split(":")[:2]
|
||||
return time(int(hours), int(minutes))
|
||||
|
||||
|
||||
def local_date_from_iso(value: Any) -> str | None:
|
||||
"""The calendar date (``YYYY-MM-DD``) of an ISO timestamp *in HA's local
|
||||
zone*. ``value[:10]`` is right for stamps written locally but off by one
|
||||
around midnight for imported ``+00:00`` stamps; this is the one place
|
||||
that does it properly. ``None`` when the value is not a timestamp."""
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return value[:10] if len(value) >= 10 and value[4] == "-" else None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.date().isoformat()
|
||||
return dt_util.as_local(parsed).date().isoformat()
|
||||
|
||||
|
||||
def normalize_hhmm(value: Any) -> str | None:
|
||||
"""Canonical ``"HH:MM"`` for a stored time, dropping TimeSelector seconds;
|
||||
``None`` when the value does not parse (the caller drops the field)."""
|
||||
parsed = parse_hhmm(value)
|
||||
return f"{parsed.hour:02d}:{parsed.minute:02d}" if parsed is not None else None
|
||||
|
||||
|
||||
def parse_iso_date(value: str | None) -> date | None:
|
||||
"""ISO date string → ``date``; ``None`` when absent or malformed."""
|
||||
if not value:
|
||||
|
||||
@@ -27,7 +27,7 @@ from typing import Any
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..const import DOMAIN, GLOBAL_UNIQUE_ID
|
||||
from .documents import KIND_FILE, KIND_WEBLINK
|
||||
from .documents import KIND_WEBLINK, async_rewrite_doc_refs, doc_wire_dict
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -132,34 +132,12 @@ def build_documents_archive(hass: HomeAssistant, entry_ids: set[str] | None = No
|
||||
continue
|
||||
docs = []
|
||||
for d in store.for_object(object_id):
|
||||
if d.get("kind") == KIND_WEBLINK:
|
||||
docs.append(
|
||||
{
|
||||
"kind": KIND_WEBLINK,
|
||||
"url": d.get("url"),
|
||||
"title": d.get("title"),
|
||||
"tags": d.get("tags") or [],
|
||||
"task_ids": d.get("task_ids") or [],
|
||||
"part_ids": d.get("part_ids") or [],
|
||||
}
|
||||
)
|
||||
else:
|
||||
h = d.get("hash")
|
||||
docs.append(
|
||||
{
|
||||
"kind": KIND_FILE,
|
||||
"hash": h,
|
||||
"title": d.get("title"),
|
||||
"filename": d.get("filename"),
|
||||
"mime": d.get("mime"),
|
||||
"size": d.get("size"),
|
||||
"tags": d.get("tags") or [],
|
||||
"task_ids": d.get("task_ids") or [],
|
||||
"part_ids": d.get("part_ids") or [],
|
||||
}
|
||||
)
|
||||
if isinstance(h, str):
|
||||
blob_hashes.add(h)
|
||||
# The same record the JSON export writes (id included, so a
|
||||
# restore can re-point completion photos / part doc links).
|
||||
docs.append(doc_wire_dict(d, include_id=True))
|
||||
h = d.get("hash")
|
||||
if d.get("kind") != KIND_WEBLINK and isinstance(h, str):
|
||||
blob_hashes.add(h)
|
||||
if docs:
|
||||
manifest_objects.append({"object_id": object_id, "object_name": obj.get("name", ""), "documents": docs})
|
||||
|
||||
@@ -255,6 +233,10 @@ async def import_documents_archive(hass: HomeAssistant, data: bytes) -> dict[str
|
||||
ids, by_name = _object_name_map(hass)
|
||||
docs_created = 0
|
||||
objects_matched = 0
|
||||
# old → new document ids across every restored object, so history photos
|
||||
# and part doc links that pointed at the archived ids follow (the JSON
|
||||
# importer does the same for its own restore).
|
||||
doc_id_map: dict[str, str] = {}
|
||||
for obj in manifest.get("objects", []):
|
||||
if not isinstance(obj, dict):
|
||||
continue
|
||||
@@ -280,8 +262,10 @@ async def import_documents_archive(hass: HomeAssistant, data: bytes) -> dict[str
|
||||
identity = {tid: tid for tid in valid_task_ids}
|
||||
part_identity = {pid: pid for pid in _object_part_ids(hass, target)}
|
||||
docs_created += await store.async_import_documents(
|
||||
target, fresh, task_id_map=identity, part_id_map=part_identity
|
||||
target, fresh, task_id_map=identity, part_id_map=part_identity, id_map=doc_id_map
|
||||
)
|
||||
if doc_id_map:
|
||||
await async_rewrite_doc_refs(hass, lambda old: doc_id_map.get(old, old))
|
||||
|
||||
return {
|
||||
"blobs_written": written,
|
||||
|
||||
@@ -16,7 +16,9 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from uuid import uuid4
|
||||
@@ -26,7 +28,15 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from ..const import DOMAIN, MAX_DOCS_PER_OBJECT, SIGNAL_DOCUMENTS_UPDATED
|
||||
from ..const import (
|
||||
CONF_PARTS,
|
||||
CONF_TASKS,
|
||||
DOMAIN,
|
||||
GLOBAL_UNIQUE_ID,
|
||||
MAX_DOCS_PER_OBJECT,
|
||||
MAX_TEXT_LENGTH,
|
||||
SIGNAL_DOCUMENTS_UPDATED,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .document_text import DocumentTextIndex
|
||||
@@ -49,6 +59,132 @@ KIND_FILE = "file"
|
||||
KIND_WEBLINK = "weblink"
|
||||
|
||||
|
||||
def doc_wire_dict(doc: dict[str, Any], *, include_id: bool) -> dict[str, Any]:
|
||||
"""A document's portable metadata — the record the JSON export AND the
|
||||
documents ZIP archive write, and ``async_import_documents`` reads back.
|
||||
|
||||
One builder so the two cannot drift: the archive used to omit ``id``
|
||||
(so a ZIP restore never filled the import's id map and completion
|
||||
photos / part ``doc_id`` links were only re-pointed on a JSON restore)
|
||||
and ``task_pages``. ``include_id`` is False only for callers that
|
||||
deliberately anonymise (none today — the parameter documents the choice).
|
||||
"""
|
||||
out: dict[str, Any] = {}
|
||||
if include_id:
|
||||
out["id"] = doc.get("id")
|
||||
if doc.get("kind") == KIND_WEBLINK:
|
||||
out.update({"kind": KIND_WEBLINK, "url": doc.get("url")})
|
||||
else:
|
||||
out.update({"kind": KIND_FILE, "hash": doc.get("hash")})
|
||||
out["title"] = doc.get("title")
|
||||
if doc.get("kind") != KIND_WEBLINK:
|
||||
out.update({"filename": doc.get("filename"), "mime": doc.get("mime"), "size": doc.get("size")})
|
||||
out.update(
|
||||
{
|
||||
"tags": doc.get("tags") or [],
|
||||
"description": doc.get("description") or "",
|
||||
"task_ids": doc.get("task_ids") or [],
|
||||
"part_ids": doc.get("part_ids") or [],
|
||||
}
|
||||
)
|
||||
pages = doc.get("task_pages")
|
||||
if isinstance(pages, dict) and pages:
|
||||
out["task_pages"] = dict(pages)
|
||||
return out
|
||||
|
||||
|
||||
async def async_rewrite_doc_refs(hass: HomeAssistant, rewrite: Callable[[str], str | None]) -> int:
|
||||
"""Apply ``rewrite`` to every document reference held OUTSIDE the
|
||||
document store: completion photos on history entries (the per-object
|
||||
Store) and the spare parts' ``doc_id`` (entry data). ``rewrite(old)``
|
||||
returns the id to keep, or ``None`` to drop the reference. Returns the
|
||||
number of references changed. The one walker behind
|
||||
:func:`async_forget_doc_ids` (document delete) and the archive import's
|
||||
re-pointing of restored documents.
|
||||
"""
|
||||
from .completion_photos import history_photo_ids
|
||||
|
||||
changed_total = 0
|
||||
for entry in hass.config_entries.async_entries(DOMAIN):
|
||||
if entry.unique_id == GLOBAL_UNIQUE_ID:
|
||||
continue
|
||||
rd = getattr(entry, "runtime_data", None)
|
||||
store = getattr(rd, "store", None) if rd else None
|
||||
if store is not None:
|
||||
store_changed = False
|
||||
for task_id in list(entry.data.get(CONF_TASKS) or {}):
|
||||
history = store.get_history(task_id)
|
||||
new_history: list[dict[str, Any]] = []
|
||||
task_changed = False
|
||||
for hist_entry in history:
|
||||
photos = history_photo_ids(hist_entry) if isinstance(hist_entry, dict) else []
|
||||
if not photos:
|
||||
new_history.append(hist_entry)
|
||||
continue
|
||||
kept: list[str] = []
|
||||
entry_changed = False
|
||||
for old in photos:
|
||||
new = rewrite(old)
|
||||
if new != old:
|
||||
entry_changed = True
|
||||
changed_total += 1
|
||||
if new is not None and new not in kept:
|
||||
kept.append(new)
|
||||
if not entry_changed and "photo_doc_id" not in hist_entry:
|
||||
new_history.append(hist_entry)
|
||||
continue
|
||||
patched = dict(hist_entry)
|
||||
patched.pop("photo_doc_id", None)
|
||||
if kept:
|
||||
patched["photo_doc_ids"] = kept
|
||||
else:
|
||||
patched.pop("photo_doc_ids", None)
|
||||
new_history.append(patched)
|
||||
task_changed = task_changed or entry_changed
|
||||
if task_changed:
|
||||
store.set_history(task_id, new_history)
|
||||
store_changed = True
|
||||
if store_changed:
|
||||
await store.async_save()
|
||||
parts = entry.data.get(CONF_PARTS) or {}
|
||||
new_parts: dict[str, Any] = {}
|
||||
parts_changed = False
|
||||
for pid, part in parts.items():
|
||||
old_doc = part.get("doc_id") if isinstance(part, dict) else None
|
||||
if isinstance(old_doc, str) and old_doc:
|
||||
new_doc = rewrite(old_doc)
|
||||
if new_doc != old_doc:
|
||||
part = {**part, "doc_id": new_doc}
|
||||
parts_changed = True
|
||||
changed_total += 1
|
||||
new_parts[pid] = part
|
||||
if parts_changed:
|
||||
hass.config_entries.async_update_entry(entry, data={**entry.data, CONF_PARTS: new_parts})
|
||||
return changed_total
|
||||
|
||||
|
||||
async def async_forget_doc_ids(hass: HomeAssistant, doc_ids: set[str]) -> int:
|
||||
"""Strip deleted document ids from every history entry's completion
|
||||
photos and every part's ``doc_id`` — the reverse cascade a document
|
||||
delete lacked (the ids dangled: a missing picture, a part link to
|
||||
nothing). Returns the number of references dropped."""
|
||||
if not doc_ids:
|
||||
return 0
|
||||
return await async_rewrite_doc_refs(hass, lambda old: None if old in doc_ids else old)
|
||||
|
||||
|
||||
def _safe_size(raw: Any) -> int:
|
||||
"""Byte size from an exported record — a non-numeric / negative / bool
|
||||
value degrades to 0 instead of raising (an unchecked ``int(...)`` here
|
||||
aborted the whole JSON import on one bad record; bug audit 2026-09-12).
|
||||
"""
|
||||
if isinstance(raw, bool) or not isinstance(raw, (int, float)):
|
||||
return 0
|
||||
if isinstance(raw, float) and not math.isfinite(raw):
|
||||
return 0
|
||||
return max(0, int(raw))
|
||||
|
||||
|
||||
class DocumentStore:
|
||||
"""Global store for maintenance-object documents (metadata + blob registry).
|
||||
|
||||
@@ -153,6 +289,12 @@ class DocumentStore:
|
||||
doc = self.documents.get(doc_id)
|
||||
return {"id": doc_id, **doc} if doc is not None else None
|
||||
|
||||
@staticmethod
|
||||
def clean_description(raw: Any) -> str:
|
||||
"""A document's free-text description (#164): one string, trimmed,
|
||||
capped like every other note; anything else is the empty string."""
|
||||
return raw.strip()[:MAX_TEXT_LENGTH] if isinstance(raw, str) else ""
|
||||
|
||||
def for_object(self, object_id: str) -> list[dict[str, Any]]:
|
||||
"""All documents attached to an object, newest first."""
|
||||
docs = [{"id": did, **d} for did, d in self.documents.items() if d.get("object_id") == object_id]
|
||||
@@ -172,6 +314,7 @@ class DocumentStore:
|
||||
mime: str,
|
||||
title: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
description: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Store an uploaded file (content-addressed + deduped) for an object.
|
||||
|
||||
@@ -213,6 +356,7 @@ class DocumentStore:
|
||||
"mime": mime,
|
||||
"size": len(content),
|
||||
"tags": list(tags or []),
|
||||
"description": self.clean_description(description),
|
||||
"task_ids": [],
|
||||
"part_ids": [],
|
||||
"added_at": dt_util.utcnow().isoformat(),
|
||||
@@ -254,6 +398,7 @@ class DocumentStore:
|
||||
url: str,
|
||||
title: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
description: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Attach an external web-link (0 storage, NOT in backups)."""
|
||||
doc_id = uuid4().hex
|
||||
@@ -263,6 +408,7 @@ class DocumentStore:
|
||||
"url": url,
|
||||
"title": title or url,
|
||||
"tags": list(tags or []),
|
||||
"description": self.clean_description(description),
|
||||
"task_ids": [],
|
||||
"part_ids": [],
|
||||
"added_at": dt_util.utcnow().isoformat(),
|
||||
@@ -308,63 +454,96 @@ class DocumentStore:
|
||||
return []
|
||||
return [part_id_map[p] for p in (meta.get("part_ids") or []) if p in part_id_map]
|
||||
|
||||
def _remap_pages(meta: dict[str, Any]) -> dict[str, int]:
|
||||
"""``task_pages`` (page hints) follow the task-id remap; a page
|
||||
for a task that did not survive the remap is dropped with it."""
|
||||
pages = meta.get("task_pages")
|
||||
if not task_id_map or not isinstance(pages, dict):
|
||||
return {}
|
||||
return {task_id_map[t]: int(p) for t, p in pages.items() if t in task_id_map and isinstance(p, int) and not isinstance(p, bool) and p >= 1}
|
||||
|
||||
created = 0
|
||||
for meta in docs:
|
||||
if not isinstance(meta, dict):
|
||||
continue
|
||||
tags = [x for x in (meta.get("tags") or []) if isinstance(x, str)]
|
||||
title = meta.get("title")
|
||||
if meta.get("kind") == KIND_WEBLINK:
|
||||
url = meta.get("url")
|
||||
# Only http(s) links — the add-link WS path enforces the same, so
|
||||
# a crafted export can't smuggle a javascript:/data: URL that the
|
||||
# frontend would later window.open (matches ws_documents_add_link).
|
||||
if not isinstance(url, str) or not url.lower().startswith(("http://", "https://")):
|
||||
continue
|
||||
new_id = uuid4().hex
|
||||
_remember(meta, new_id)
|
||||
self.documents[new_id] = {
|
||||
"object_id": object_id,
|
||||
"kind": KIND_WEBLINK,
|
||||
"url": url,
|
||||
"title": title or url,
|
||||
"tags": tags,
|
||||
"task_ids": _remap(meta),
|
||||
"part_ids": _remap_parts(meta),
|
||||
"added_at": dt_util.utcnow().isoformat(),
|
||||
}
|
||||
created += 1
|
||||
elif meta.get("kind") == KIND_FILE:
|
||||
digest = meta.get("hash")
|
||||
if not isinstance(digest, str) or len(digest) != 64 or not all(c in "0123456789abcdef" for c in digest):
|
||||
continue
|
||||
size = int(meta.get("size") or 0)
|
||||
mime = meta.get("mime") or "application/octet-stream"
|
||||
blob = self.blobs.get(digest)
|
||||
if blob is None:
|
||||
blob = {"size": size, "mime": mime, "refcount": 0}
|
||||
self.blobs[digest] = blob
|
||||
blob["refcount"] += 1
|
||||
new_id = uuid4().hex
|
||||
_remember(meta, new_id)
|
||||
self.documents[new_id] = {
|
||||
"object_id": object_id,
|
||||
"kind": KIND_FILE,
|
||||
"hash": digest,
|
||||
"title": title or meta.get("filename") or "document",
|
||||
"filename": meta.get("filename") or "document",
|
||||
"mime": mime,
|
||||
"size": size,
|
||||
"tags": tags,
|
||||
"task_ids": _remap(meta),
|
||||
"part_ids": _remap_parts(meta),
|
||||
"added_at": dt_util.utcnow().isoformat(),
|
||||
}
|
||||
created += 1
|
||||
# One malformed record (tags not a list, size a string, …) must
|
||||
# skip THAT record, not abort the caller's whole import — the
|
||||
# JSON importer calls this outside its per-object try
|
||||
# (bug audit 2026-09-12).
|
||||
try:
|
||||
created += self._import_one_document(meta, object_id, _remember, _remap, _remap_parts, _remap_pages)
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
_LOGGER.warning("Skipping malformed document record %r during import", meta.get("id"))
|
||||
if created:
|
||||
await self._async_save()
|
||||
return created
|
||||
|
||||
def _import_one_document(
|
||||
self,
|
||||
meta: dict[str, Any],
|
||||
object_id: str,
|
||||
remember: Callable[[dict[str, Any], str], None],
|
||||
remap: Callable[[dict[str, Any]], list[str]],
|
||||
remap_parts: Callable[[dict[str, Any]], list[str]],
|
||||
remap_pages: Callable[[dict[str, Any]], dict[str, int]],
|
||||
) -> int:
|
||||
"""Recreate ONE exported document record; returns 1 if created, else 0."""
|
||||
tags = [x for x in (meta.get("tags") or []) if isinstance(x, str)]
|
||||
title = meta.get("title")
|
||||
pages = remap_pages(meta)
|
||||
if meta.get("kind") == KIND_WEBLINK:
|
||||
url = meta.get("url")
|
||||
# Only http(s) links — the add-link WS path enforces the same, so
|
||||
# a crafted export can't smuggle a javascript:/data: URL that the
|
||||
# frontend would later window.open (matches ws_documents_add_link).
|
||||
if not isinstance(url, str) or not url.lower().startswith(("http://", "https://")):
|
||||
return 0
|
||||
new_id = uuid4().hex
|
||||
remember(meta, new_id)
|
||||
self.documents[new_id] = {
|
||||
"object_id": object_id,
|
||||
"kind": KIND_WEBLINK,
|
||||
"url": url,
|
||||
"title": title or url,
|
||||
"tags": tags,
|
||||
"description": self.clean_description(meta.get("description")),
|
||||
"task_ids": remap(meta),
|
||||
"part_ids": remap_parts(meta),
|
||||
**({"task_pages": pages} if pages else {}),
|
||||
"added_at": dt_util.utcnow().isoformat(),
|
||||
}
|
||||
return 1
|
||||
if meta.get("kind") == KIND_FILE:
|
||||
digest = meta.get("hash")
|
||||
if not isinstance(digest, str) or len(digest) != 64 or not all(c in "0123456789abcdef" for c in digest):
|
||||
return 0
|
||||
size = _safe_size(meta.get("size"))
|
||||
mime = meta.get("mime") or "application/octet-stream"
|
||||
blob = self.blobs.get(digest)
|
||||
if blob is None:
|
||||
blob = {"size": size, "mime": mime, "refcount": 0}
|
||||
self.blobs[digest] = blob
|
||||
blob["refcount"] += 1
|
||||
new_id = uuid4().hex
|
||||
remember(meta, new_id)
|
||||
self.documents[new_id] = {
|
||||
"object_id": object_id,
|
||||
"kind": KIND_FILE,
|
||||
"hash": digest,
|
||||
"title": title or meta.get("filename") or "document",
|
||||
"filename": meta.get("filename") or "document",
|
||||
"mime": mime,
|
||||
"size": size,
|
||||
"tags": tags,
|
||||
"description": self.clean_description(meta.get("description")),
|
||||
"task_ids": remap(meta),
|
||||
"part_ids": remap_parts(meta),
|
||||
**({"task_pages": pages} if pages else {}),
|
||||
"added_at": dt_util.utcnow().isoformat(),
|
||||
}
|
||||
return 1
|
||||
return 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Update
|
||||
# ------------------------------------------------------------------
|
||||
@@ -378,8 +557,9 @@ class DocumentStore:
|
||||
task_ids: list[str] | None = None,
|
||||
task_pages: dict[str, int] | None = None,
|
||||
part_ids: list[str] | None = None,
|
||||
description: str | None = None,
|
||||
) -> bool:
|
||||
"""Update editable metadata (title / tags / task+part links / per-task page).
|
||||
"""Update editable metadata (title / tags / description / task+part links / per-task page).
|
||||
|
||||
``task_pages`` is a ``{task_id: page}`` map merged into the doc: a page
|
||||
``>= 1`` sets the jump-to page for that task's link, ``0`` clears it. Page
|
||||
@@ -394,6 +574,8 @@ class DocumentStore:
|
||||
doc["title"] = title
|
||||
if tags is not None:
|
||||
doc["tags"] = list(tags)
|
||||
if description is not None:
|
||||
doc["description"] = self.clean_description(description)
|
||||
if task_ids is not None:
|
||||
doc["task_ids"] = list(task_ids)
|
||||
if part_ids is not None:
|
||||
@@ -464,6 +646,62 @@ class DocumentStore:
|
||||
await self._async_save()
|
||||
return touched
|
||||
|
||||
def task_links(self, task_id: str) -> dict[str, int | None]:
|
||||
"""``{doc_id: page hint or None}`` for every document linked to a task.
|
||||
|
||||
The snapshot ``task/move`` takes BEFORE its delete leg runs
|
||||
:meth:`async_unlink_task`, so the links can be restored afterwards.
|
||||
"""
|
||||
links: dict[str, int | None] = {}
|
||||
for doc_id, doc in self.documents.items():
|
||||
linked = doc.get("task_ids")
|
||||
if isinstance(linked, list) and task_id in linked:
|
||||
pages = doc.get("task_pages")
|
||||
page = pages.get(task_id) if isinstance(pages, dict) else None
|
||||
links[doc_id] = page if isinstance(page, int) and page >= 1 else None
|
||||
return links
|
||||
|
||||
async def async_relink_task(
|
||||
self,
|
||||
task_id: str,
|
||||
links: dict[str, int | None],
|
||||
*,
|
||||
rehome_doc_ids: set[str] | None = None,
|
||||
object_id: str | None = None,
|
||||
) -> int:
|
||||
"""Restore task links dropped by :meth:`async_unlink_task`; optionally
|
||||
re-home some of the docs to another object. Returns the docs touched.
|
||||
|
||||
``task/move`` (bug audit 2026-09-12): the moved task's completion
|
||||
photos are documents owned by the SOURCE object — when that object is
|
||||
later deleted, ``async_remove_object`` takes the photos with it and
|
||||
the history entries' ``photo_doc_ids`` dangle. Documents carry only
|
||||
an ``object_id``, so re-homing is a plain re-stamp; the caller decides
|
||||
which docs move (photos linked to nothing but this task) and which
|
||||
merely keep their link (a manual shared with the object's other
|
||||
tasks). Unknown doc ids are skipped.
|
||||
"""
|
||||
touched = 0
|
||||
rehome = rehome_doc_ids or set()
|
||||
for doc_id, page in links.items():
|
||||
doc = self.documents.get(doc_id)
|
||||
if doc is None:
|
||||
continue
|
||||
linked = list(doc.get("task_ids") or [])
|
||||
if task_id not in linked:
|
||||
linked.append(task_id)
|
||||
doc["task_ids"] = linked
|
||||
if page is not None:
|
||||
pages = dict(doc.get("task_pages") or {})
|
||||
pages[task_id] = page
|
||||
doc["task_pages"] = pages
|
||||
if object_id and doc_id in rehome:
|
||||
doc["object_id"] = object_id
|
||||
touched += 1
|
||||
if touched:
|
||||
await self._async_save()
|
||||
return touched
|
||||
|
||||
async def async_remove_object(self, object_id: str) -> int:
|
||||
"""Remove every document of an object (on object delete). Bytes freed."""
|
||||
doc_ids = [did for did, d in self.documents.items() if d.get("object_id") == object_id]
|
||||
|
||||
@@ -119,8 +119,7 @@ def rewrite_store(store: Any, old_id: str, new_id: str) -> bool:
|
||||
"""
|
||||
changed = False
|
||||
compound_key = re.compile(r"_compound_(\d+)_" + re.escape(old_id))
|
||||
tasks_state: dict[str, Any] = store._data.get("tasks", {})
|
||||
for state in tasks_state.values():
|
||||
for state in store.all_task_states().values():
|
||||
ac = state.get("adaptive_config")
|
||||
if isinstance(ac, dict) and ac.get("environmental_entity") == old_id:
|
||||
state["adaptive_config"] = {**ac, "environmental_entity": new_id}
|
||||
|
||||
@@ -32,6 +32,7 @@ from ..const import (
|
||||
PANEL_TITLE,
|
||||
THRESHOLD_PERCENT_RANGE,
|
||||
)
|
||||
from .settings_registry import setting_default
|
||||
|
||||
|
||||
def get_global_entry(hass: HomeAssistant) -> ConfigEntry | None:
|
||||
@@ -52,6 +53,13 @@ def get_global_options(hass: HomeAssistant) -> Mapping[str, Any]:
|
||||
return (entry.options or entry.data) if entry is not None else {}
|
||||
|
||||
|
||||
def global_option(hass: HomeAssistant, key: str) -> Any:
|
||||
"""One global setting, falling back to the registry default when unset —
|
||||
the runtime readers' accessor (coordinator, notification manager), so the
|
||||
fallback is never hand-typed next to the key."""
|
||||
return get_global_options(hass).get(key, setting_default(key))
|
||||
|
||||
|
||||
def is_schedule_time_enabled(hass: HomeAssistant) -> bool:
|
||||
"""The global advanced flag for time-of-day scheduling.
|
||||
|
||||
|
||||
@@ -34,8 +34,17 @@ from ..const import (
|
||||
SOUTHERN_SEASONS,
|
||||
MaintenanceFeedback,
|
||||
)
|
||||
from .history import completed_entries
|
||||
from .schedule import read_legacy_fields
|
||||
|
||||
|
||||
def hemisphere(hass: Any) -> str:
|
||||
"""``"south"`` / ``"north"`` from HA's configured latitude — the seasonal
|
||||
anchor the adaptive analysis takes. ``None`` (an un-onboarded HA) reads
|
||||
as north. Duck-typed on ``hass.config.latitude`` so the pure-function
|
||||
tests need no HomeAssistant."""
|
||||
return "south" if (hass.config.latitude or 0) < 0 else "north"
|
||||
|
||||
# Feedback multipliers for EWA: adjusts effective interval based on need
|
||||
FEEDBACK_MULTIPLIERS: dict[str, float] = {
|
||||
MaintenanceFeedback.NEEDED: 1.0, # interval was right or too long
|
||||
@@ -312,9 +321,7 @@ class IntervalAnalyzer:
|
||||
Sorted list of completion datetimes (TZ-aware).
|
||||
"""
|
||||
completed_dates: list[datetime] = []
|
||||
for entry in history:
|
||||
if entry.get("type") != "completed":
|
||||
continue
|
||||
for entry in completed_entries(history):
|
||||
ts = entry.get("timestamp")
|
||||
if not ts:
|
||||
continue
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -28,6 +28,8 @@ from typing import Any
|
||||
from urllib.parse import quote_plus
|
||||
from uuid import uuid4
|
||||
|
||||
from ..const import DEFAULT_CURRENCY_DECIMALS
|
||||
|
||||
# ── Limits (mirrored in the WS schemas + panel dialog) ──────────────────────
|
||||
MAX_PARTS_PER_OBJECT = 50
|
||||
MAX_PART_NAME = 100
|
||||
@@ -350,7 +352,7 @@ def buy_task_name(part_name: str, lang: str) -> str:
|
||||
return tpl.replace("{name}", part_name)
|
||||
|
||||
|
||||
def buy_task_notes(part: Mapping[str, Any], stock: float | None) -> str:
|
||||
def buy_task_notes(part: Mapping[str, Any], stock: float | None, decimals: int = DEFAULT_CURRENCY_DECIMALS) -> str:
|
||||
"""Self-contained purchase notes: identifiers, qty, price, storage spot.
|
||||
|
||||
Deliberately mostly language-neutral (labels are identifiers like MPN/GTIN;
|
||||
@@ -372,7 +374,7 @@ def buy_task_notes(part: Mapping[str, Any], stock: float | None) -> str:
|
||||
if idents:
|
||||
lines.append(idents)
|
||||
if part.get("cost") is not None:
|
||||
lines.append(f"≈ {part['cost']:.2f} × {qty}")
|
||||
lines.append(f"≈ {part['cost']:.{decimals}f} × {qty}")
|
||||
if stock is not None:
|
||||
lines.append(f"◎ {stock}")
|
||||
if part.get("storage_location"):
|
||||
@@ -388,6 +390,7 @@ def build_buy_task(
|
||||
lang: str,
|
||||
search_template: str | None,
|
||||
today: date,
|
||||
decimals: int = DEFAULT_CURRENCY_DECIMALS,
|
||||
) -> dict[str, Any]:
|
||||
"""The one-off shopping reminder for a low part (due today, actionable now)."""
|
||||
return {
|
||||
@@ -401,7 +404,7 @@ def build_buy_task(
|
||||
"created_at": today.isoformat(),
|
||||
"labels": [BUY_TASK_LABEL],
|
||||
"custom_icon": BUY_TASK_ICON,
|
||||
"notes": buy_task_notes(part, stock),
|
||||
"notes": buy_task_notes(part, stock, decimals),
|
||||
"documentation_url": resolve_shopping_url(part, search_template, lang),
|
||||
PART_REF_FIELD: {"part_id": str(part["id"])},
|
||||
}
|
||||
@@ -416,6 +419,7 @@ def reconcile_buy_tasks(
|
||||
lang: str,
|
||||
search_template: str | None,
|
||||
today: date,
|
||||
decimals: int = DEFAULT_CURRENCY_DECIMALS,
|
||||
is_task_done: Any,
|
||||
) -> tuple[dict[str, dict[str, Any]], list[str], list[str], bool]:
|
||||
"""Compute the task map with auto "buy" reminders synced to low parts.
|
||||
@@ -477,6 +481,7 @@ def reconcile_buy_tasks(
|
||||
lang=lang,
|
||||
search_template=search_template,
|
||||
today=today,
|
||||
decimals=decimals,
|
||||
)
|
||||
result[task["id"]] = task
|
||||
created.append(task["id"])
|
||||
|
||||
@@ -35,6 +35,16 @@ def is_object_paused(obj: dict[str, Any]) -> bool:
|
||||
return obj.get("paused_at") is not None
|
||||
|
||||
|
||||
def is_task_inert(task_data: dict[str, Any], obj: dict[str, Any]) -> bool:
|
||||
"""True when nothing may happen to the task: it is archived, disabled or
|
||||
its object is paused. The one predicate behind the coordinator's
|
||||
complete / skip / reset / postpone gates and the silent auto-recovery
|
||||
refusal — three hand-copied spellings before, and reset/postpone had
|
||||
none (a stale notification button or an old NFC sticker could restart
|
||||
a retired task's cycle)."""
|
||||
return task_data.get("archived_at") is not None or task_data.get("enabled") is False or is_object_paused(obj)
|
||||
|
||||
|
||||
def pause_due_for_auto_resume(obj: dict[str, Any], today: date) -> bool:
|
||||
"""True when a paused object's ``paused_until`` day has been reached."""
|
||||
until = obj.get("paused_until")
|
||||
@@ -60,6 +70,25 @@ def clear_cycle_modifiers(task_state: dict[str, Any]) -> None:
|
||||
task_state.pop("due_override", None)
|
||||
|
||||
|
||||
def write_anchor(task_state: dict[str, Any], date_str: str | None, *, clear_modifiers: bool = True) -> None:
|
||||
"""Write (or with ``None`` clear) a task dict's ``last_performed`` anchor.
|
||||
|
||||
The ONE place an anchor is poked into a task-state dict — the Store's
|
||||
:meth:`MaintenanceStore.set_anchor`, the CSV importer and the config
|
||||
flows all go through it. Moving the anchor abandons the cycle it
|
||||
described, so the per-occurrence modifiers (``due_override`` from a
|
||||
postpone, ``last_planned_due``) go with it unless the caller says
|
||||
otherwise: a history edit that deleted the last completion used to leave
|
||||
the postponed date in place, and ``next_due`` kept returning it.
|
||||
"""
|
||||
if date_str is None:
|
||||
task_state.pop("last_performed", None)
|
||||
else:
|
||||
task_state["last_performed"] = date_str
|
||||
if clear_modifiers:
|
||||
clear_cycle_modifiers(task_state)
|
||||
|
||||
|
||||
def reanchor_recurring_task(
|
||||
task_id: str,
|
||||
*,
|
||||
@@ -91,8 +120,7 @@ def reanchor_recurring_task(
|
||||
if store is None:
|
||||
task_data["last_performed"] = today_iso
|
||||
if store is not None:
|
||||
store.set_last_performed(task_id, today_iso)
|
||||
clear_cycle_modifiers(store._ensure_task(task_id))
|
||||
store.set_anchor(task_id, today_iso)
|
||||
|
||||
|
||||
def build_resumed_entry_data(
|
||||
|
||||
@@ -32,6 +32,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from ..const import ARCHIVE_REASON_AUTO
|
||||
from .dates import local_date_from_iso
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -40,13 +41,14 @@ def _to_date(value: Any) -> date | None:
|
||||
"""Coerce a stored date / ISO timestamp string to a ``date`` (or None).
|
||||
|
||||
Accepts both ``"2026-06-24"`` (last_performed) and a full ISO timestamp
|
||||
``"2026-06-24T12:00:00+00:00"`` (archived_at) — the leading ``YYYY-MM-DD``
|
||||
is all the day-granular policy needs.
|
||||
``"2026-06-24T12:00:00+00:00"`` (archived_at) — the stamp's LOCAL
|
||||
calendar day is all the day-granular policy needs.
|
||||
"""
|
||||
if not isinstance(value, str) or len(value) < 10:
|
||||
day = local_date_from_iso(value)
|
||||
if day is None:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(value[:10])
|
||||
return date.fromisoformat(day)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ from ..const import (
|
||||
DEGRADATION_CYCLE_SETTLE_POINTS,
|
||||
DEGRADATION_MIN_CYCLE_HOURS,
|
||||
)
|
||||
from .history import completed_entries
|
||||
from .schedule import read_legacy_fields
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -632,7 +633,7 @@ class SensorPredictor:
|
||||
|
||||
# Extract completion intervals with env values at completion time
|
||||
history = task_data.get("history") or []
|
||||
completed = [h for h in history if h.get("type") == "completed" and h.get("timestamp")]
|
||||
completed = [h for h in completed_entries(history) if h.get("timestamp")]
|
||||
completed.sort(key=lambda h: h["timestamp"])
|
||||
|
||||
if len(completed) < 2:
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
"""Single source of truth for global-setting validation.
|
||||
"""Single source of truth for global-setting validation AND defaults.
|
||||
|
||||
Each writable global setting is declared ONCE here as a ``SettingSpec`` (key +
|
||||
type + optional numeric range / string cap). The WS write handler
|
||||
type + default + optional numeric range / string cap). The WS write handler
|
||||
(``websocket/dashboard.py``) derives its allow-list and range/cap tables from
|
||||
this registry, and the options flow (``config_flow_options_global.py``) pulls
|
||||
its NumberSelector min/max from the same specs — so the ranges can't drift
|
||||
between the two surfaces (they previously lived as three hand-kept copies).
|
||||
|
||||
The ``default`` is what an unset option means everywhere: the WS ``settings``
|
||||
echo, the options-flow form defaults and the runtime readers (notification
|
||||
manager, coordinator) all go through :func:`setting_default`, so "24 h" for
|
||||
the due-soon repeat or "off" for completion notifications is written once
|
||||
(DRY review 2026-09-12 — the values used to live as four hand-kept copies).
|
||||
``tests/test_settings_defaults.py`` pins the three surfaces to this table.
|
||||
|
||||
Bespoke normalisation that isn't a plain range/cap (panel-title trim,
|
||||
title-style enum, quiet-hours regex, list sanitisers, notify-service
|
||||
validation) stays in the WS handler; the registry only covers the mechanical
|
||||
@@ -15,7 +22,9 @@ type + range + length checks.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -34,12 +43,14 @@ from ..const import (
|
||||
CONF_ADVANCED_SCHEDULE_TIME,
|
||||
CONF_ADVANCED_SEASONAL,
|
||||
CONF_ARCHIVE_ONEOFF_DAYS,
|
||||
CONF_BATTERY_LIFETIME_MONTHS,
|
||||
CONF_BATTERY_LOW_PERCENT,
|
||||
CONF_BUDGET_ALERT_THRESHOLD,
|
||||
CONF_BUDGET_ALERTS_ENABLED,
|
||||
CONF_BUDGET_CURRENCY,
|
||||
CONF_BUDGET_MONTHLY,
|
||||
CONF_BUDGET_YEARLY,
|
||||
CONF_CURRENCY_DECIMALS,
|
||||
CONF_DEFAULT_CONSUMABLE_THRESHOLD,
|
||||
CONF_DEFAULT_WARNING_DAYS,
|
||||
CONF_DELETE_ARCHIVED_ONEOFF_DAYS,
|
||||
@@ -51,6 +62,7 @@ from ..const import (
|
||||
CONF_NOTIFICATION_BUNDLING_ENABLED,
|
||||
CONF_NOTIFICATION_TITLE_STYLE,
|
||||
CONF_NOTIFICATIONS_ENABLED,
|
||||
CONF_NOTIFY_COMPLETED,
|
||||
CONF_NOTIFY_DUE_SOON_ENABLED,
|
||||
CONF_NOTIFY_DUE_SOON_INTERVAL,
|
||||
CONF_NOTIFY_EVENT_ONLY,
|
||||
@@ -68,6 +80,7 @@ from ..const import (
|
||||
CONF_QUIET_HOURS_ENABLED,
|
||||
CONF_QUIET_HOURS_END,
|
||||
CONF_QUIET_HOURS_START,
|
||||
CONF_REF_NUMBERS_IN_LISTS,
|
||||
CONF_REMINDER_LEAD_DAYS,
|
||||
CONF_ROW_ACTION_NOTICE,
|
||||
CONF_ROW_ACTION_STYLE,
|
||||
@@ -76,16 +89,30 @@ from ..const import (
|
||||
CONF_WARRANTY_REMINDER_DAYS,
|
||||
CONF_WARRANTY_REMINDER_ENABLED,
|
||||
CONF_WEEKLY_DIGEST_ENABLED,
|
||||
DEFAULT_ARCHIVE_ONEOFF_DAYS,
|
||||
DEFAULT_BATTERY_LOW_PERCENT,
|
||||
DEFAULT_BUDGET_CURRENCY,
|
||||
DEFAULT_CONSUMABLE_THRESHOLD,
|
||||
DEFAULT_CURRENCY_DECIMALS,
|
||||
DEFAULT_DELETE_ARCHIVED_ONEOFF_DAYS,
|
||||
DEFAULT_MAX_NOTIFICATIONS_PER_DAY,
|
||||
DEFAULT_OBJECTS_TABLE_COLUMNS,
|
||||
DEFAULT_PANEL_ENABLED,
|
||||
DEFAULT_ROW_ACTION_STYLE,
|
||||
DEFAULT_SNOOZE_DURATION_HOURS,
|
||||
DEFAULT_WARNING_DAYS,
|
||||
DEFAULT_WARRANTY_REMINDER_DAYS,
|
||||
MAX_NOTIFY_EXTRA_DATA_LENGTH,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SettingSpec:
|
||||
"""Validation spec for one writable global setting."""
|
||||
"""Validation spec + default for one writable global setting."""
|
||||
|
||||
key: str
|
||||
py_type: type # int | float | bool | str | list — used for isinstance()
|
||||
default: Any = None # what an unset option means (never None — see the tripwire)
|
||||
int_range: tuple[int, int] | None = None
|
||||
float_range: tuple[float, float] | None = None
|
||||
max_len: int | None = None # string length cap
|
||||
@@ -97,88 +124,109 @@ SETTING_SPECS: tuple[SettingSpec, ...] = (
|
||||
# General
|
||||
# 0 = no warning window: new tasks turn due_soon on the due date itself
|
||||
# (the per-task range already allowed 0 — #145).
|
||||
SettingSpec(CONF_DEFAULT_WARNING_DAYS, int, int_range=(0, 365)),
|
||||
SettingSpec(CONF_DEFAULT_WARNING_DAYS, int, DEFAULT_WARNING_DAYS, int_range=(0, 365)),
|
||||
# #146: household "low" floors (percent) for discovery and the battery fleet.
|
||||
SettingSpec(CONF_DEFAULT_CONSUMABLE_THRESHOLD, int, int_range=(1, 90)),
|
||||
SettingSpec(CONF_BATTERY_LOW_PERCENT, int, int_range=(1, 90)),
|
||||
SettingSpec(CONF_NOTIFICATIONS_ENABLED, bool),
|
||||
SettingSpec(CONF_NOTIFY_SERVICE, str, max_len=200),
|
||||
SettingSpec(CONF_DEFAULT_CONSUMABLE_THRESHOLD, int, DEFAULT_CONSUMABLE_THRESHOLD, int_range=(1, 90)),
|
||||
SettingSpec(CONF_BATTERY_LOW_PERCENT, int, DEFAULT_BATTERY_LOW_PERCENT, int_range=(1, 90)),
|
||||
# D#162 follow-up: {battery type: typical lifetime in months} — the forecast
|
||||
# anchor for batteries without a level sensor (sanitised in dashboard.py).
|
||||
SettingSpec(CONF_BATTERY_LIFETIME_MONTHS, dict, {}),
|
||||
SettingSpec(CONF_NOTIFICATIONS_ENABLED, bool, False),
|
||||
SettingSpec(CONF_NOTIFY_SERVICE, str, "", max_len=200),
|
||||
# todo.* entity the buy-task shopping sync mirrors into ("" = off);
|
||||
# format-validated by a bespoke sanitiser rule (todo. prefix), max_len is
|
||||
# HA's entity-id cap.
|
||||
SettingSpec(CONF_SHOPPING_LIST_ENTITY, str, max_len=255),
|
||||
SettingSpec(CONF_SHOPPING_LIST_ENTITY, str, "", max_len=255),
|
||||
# #145: enum-validated by a bespoke rule (ROW_ACTION_STYLES); the notice
|
||||
# flag is only ever cleared by the panel banner.
|
||||
SettingSpec(CONF_ROW_ACTION_STYLE, str, max_len=32),
|
||||
SettingSpec(CONF_ROW_ACTION_NOTICE, bool),
|
||||
SettingSpec(CONF_PANEL_ENABLED, bool),
|
||||
SettingSpec(CONF_ROW_ACTION_STYLE, str, DEFAULT_ROW_ACTION_STYLE, max_len=32),
|
||||
SettingSpec(CONF_REF_NUMBERS_IN_LISTS, bool, False),
|
||||
SettingSpec(CONF_ROW_ACTION_NOTICE, bool, False),
|
||||
SettingSpec(CONF_PANEL_ENABLED, bool, DEFAULT_PANEL_ENABLED),
|
||||
# panel_title is trimmed+capped to MAX_PANEL_TITLE_LENGTH by a bespoke rule,
|
||||
# not a plain drop-if-too-long — so no max_len here.
|
||||
SettingSpec(CONF_PANEL_TITLE, str),
|
||||
SettingSpec(CONF_PANEL_TITLE, str, ""),
|
||||
# Advanced-feature toggles
|
||||
SettingSpec(CONF_ADVANCED_ADAPTIVE, bool),
|
||||
SettingSpec(CONF_ADVANCED_PREDICTIONS, bool),
|
||||
SettingSpec(CONF_ADVANCED_SEASONAL, bool),
|
||||
SettingSpec(CONF_ADVANCED_ENVIRONMENTAL, bool),
|
||||
SettingSpec(CONF_ADVANCED_BUDGET, bool),
|
||||
SettingSpec(CONF_ADVANCED_GROUPS, bool),
|
||||
SettingSpec(CONF_ADVANCED_CHECKLISTS, bool),
|
||||
SettingSpec(CONF_ADVANCED_SCHEDULE_TIME, bool),
|
||||
SettingSpec(CONF_ADVANCED_COMPLETION_ACTIONS, bool),
|
||||
SettingSpec(CONF_ADVANCED_ADAPTIVE, bool, False),
|
||||
SettingSpec(CONF_ADVANCED_PREDICTIONS, bool, False),
|
||||
SettingSpec(CONF_ADVANCED_SEASONAL, bool, False),
|
||||
SettingSpec(CONF_ADVANCED_ENVIRONMENTAL, bool, False),
|
||||
SettingSpec(CONF_ADVANCED_BUDGET, bool, False),
|
||||
SettingSpec(CONF_ADVANCED_GROUPS, bool, False),
|
||||
SettingSpec(CONF_ADVANCED_CHECKLISTS, bool, False),
|
||||
SettingSpec(CONF_ADVANCED_SCHEDULE_TIME, bool, False),
|
||||
SettingSpec(CONF_ADVANCED_COMPLETION_ACTIONS, bool, False),
|
||||
# Governance (list elements sanitised by a bespoke rule in the handler)
|
||||
SettingSpec(CONF_ADMIN_PANEL_USER_IDS, list),
|
||||
SettingSpec(CONF_OPERATOR_WRITE_ENABLED, bool),
|
||||
SettingSpec(CONF_OBJECTS_TABLE_COLUMNS, list),
|
||||
SettingSpec(CONF_ADMIN_PANEL_USER_IDS, list, []),
|
||||
SettingSpec(CONF_OPERATOR_WRITE_ENABLED, bool, False),
|
||||
SettingSpec(CONF_OBJECTS_TABLE_COLUMNS, list, DEFAULT_OBJECTS_TABLE_COLUMNS),
|
||||
# #169 follow-up: member avatars (bespoke palette/initials sanitiser)
|
||||
SettingSpec(CONF_MEMBER_DISPLAY, dict),
|
||||
SettingSpec(CONF_MEMBER_DISPLAY, dict, {}),
|
||||
# v2.21: hidden template ids (bespoke known-id sanitiser in the handler)
|
||||
SettingSpec(CONF_DISABLED_TEMPLATE_IDS, list),
|
||||
SettingSpec(CONF_DISABLED_TEMPLATE_IDS, list, []),
|
||||
# Archive automation
|
||||
SettingSpec(CONF_ARCHIVE_ONEOFF_DAYS, int, int_range=(0, 3650)),
|
||||
SettingSpec(CONF_DELETE_ARCHIVED_ONEOFF_DAYS, int, int_range=(0, 3650)),
|
||||
# Notification per-status
|
||||
SettingSpec(CONF_NOTIFY_DUE_SOON_ENABLED, bool),
|
||||
SettingSpec(CONF_NOTIFY_DUE_SOON_INTERVAL, int, int_range=(0, 720)),
|
||||
SettingSpec(CONF_NOTIFY_OVERDUE_ENABLED, bool),
|
||||
SettingSpec(CONF_NOTIFY_OVERDUE_INTERVAL, int, int_range=(0, 720)),
|
||||
SettingSpec(CONF_NOTIFY_TRIGGERED_ENABLED, bool),
|
||||
SettingSpec(CONF_NOTIFY_TRIGGERED_INTERVAL, int, int_range=(0, 720)),
|
||||
SettingSpec(CONF_ARCHIVE_ONEOFF_DAYS, int, DEFAULT_ARCHIVE_ONEOFF_DAYS, int_range=(0, 3650)),
|
||||
SettingSpec(CONF_DELETE_ARCHIVED_ONEOFF_DAYS, int, DEFAULT_DELETE_ARCHIVED_ONEOFF_DAYS, int_range=(0, 3650)),
|
||||
# Notification per-status (interval 0 = notify once per status)
|
||||
SettingSpec(CONF_NOTIFY_DUE_SOON_ENABLED, bool, True),
|
||||
SettingSpec(CONF_NOTIFY_DUE_SOON_INTERVAL, int, 24, int_range=(0, 720)),
|
||||
SettingSpec(CONF_NOTIFY_OVERDUE_ENABLED, bool, True),
|
||||
SettingSpec(CONF_NOTIFY_OVERDUE_INTERVAL, int, 12, int_range=(0, 720)),
|
||||
SettingSpec(CONF_NOTIFY_TRIGGERED_ENABLED, bool, True),
|
||||
SettingSpec(CONF_NOTIFY_TRIGGERED_INTERVAL, int, 0, int_range=(0, 720)),
|
||||
# Quiet hours (HH:MM[:SS] validated by a bespoke regex in the handler)
|
||||
SettingSpec(CONF_QUIET_HOURS_ENABLED, bool),
|
||||
SettingSpec(CONF_QUIET_HOURS_START, str, max_len=5),
|
||||
SettingSpec(CONF_QUIET_HOURS_END, str, max_len=5),
|
||||
SettingSpec(CONF_QUIET_HOURS_ENABLED, bool, True),
|
||||
SettingSpec(CONF_QUIET_HOURS_START, str, "22:00", max_len=5),
|
||||
SettingSpec(CONF_QUIET_HOURS_END, str, "08:00", max_len=5),
|
||||
# Limits + bundling
|
||||
SettingSpec(CONF_MAX_NOTIFICATIONS_PER_DAY, int, int_range=(0, 1000)),
|
||||
SettingSpec(CONF_NOTIFICATION_BUNDLING_ENABLED, bool),
|
||||
SettingSpec(CONF_NOTIFICATION_BUNDLE_THRESHOLD, int, int_range=(2, 20)),
|
||||
# title_style is enum-validated by a bespoke rule in the handler.
|
||||
SettingSpec(CONF_NOTIFICATION_TITLE_STYLE, str),
|
||||
SettingSpec(CONF_NOTIFY_SCOPE_VIEW_ID, str, max_len=64),
|
||||
SettingSpec(CONF_MAX_NOTIFICATIONS_PER_DAY, int, DEFAULT_MAX_NOTIFICATIONS_PER_DAY, int_range=(0, 1000)),
|
||||
SettingSpec(CONF_NOTIFICATION_BUNDLING_ENABLED, bool, False),
|
||||
SettingSpec(CONF_NOTIFICATION_BUNDLE_THRESHOLD, int, 2, int_range=(2, 20)),
|
||||
# title_style is enum-validated by a bespoke rule in the handler
|
||||
# (v1.4.0 / #44: "default" keeps the per-status titles).
|
||||
SettingSpec(CONF_NOTIFICATION_TITLE_STYLE, str, "default"),
|
||||
# #173 follow-up: completion notifications off / automatic / all (enum-checked in dashboard.py).
|
||||
SettingSpec(CONF_NOTIFY_COMPLETED, str, "off", max_len=16),
|
||||
SettingSpec(CONF_NOTIFY_SCOPE_VIEW_ID, str, "", max_len=64),
|
||||
# #165: your own notification rule
|
||||
SettingSpec(CONF_NOTIFY_EVENT_ONLY, bool),
|
||||
SettingSpec(CONF_NOTIFY_EXTRA_DATA, str, max_len=MAX_NOTIFY_EXTRA_DATA_LENGTH),
|
||||
SettingSpec(CONF_NOTIFY_EVENT_ONLY, bool, False),
|
||||
SettingSpec(CONF_NOTIFY_EXTRA_DATA, str, "", max_len=MAX_NOTIFY_EXTRA_DATA_LENGTH),
|
||||
# Actions
|
||||
SettingSpec(CONF_ACTION_COMPLETE_ENABLED, bool),
|
||||
SettingSpec(CONF_ACTION_SKIP_ENABLED, bool),
|
||||
SettingSpec(CONF_ACTION_SNOOZE_ENABLED, bool),
|
||||
SettingSpec(CONF_SNOOZE_DURATION_HOURS, int, int_range=(1, 168)),
|
||||
SettingSpec(CONF_WEEKLY_DIGEST_ENABLED, bool),
|
||||
SettingSpec(CONF_INSTALL_ASSIST_SENTENCES, bool),
|
||||
SettingSpec(CONF_WARRANTY_REMINDER_ENABLED, bool),
|
||||
SettingSpec(CONF_WARRANTY_REMINDER_DAYS, int, int_range=(1, 365)),
|
||||
SettingSpec(CONF_ACTION_COMPLETE_ENABLED, bool, False),
|
||||
SettingSpec(CONF_ACTION_SKIP_ENABLED, bool, False),
|
||||
SettingSpec(CONF_ACTION_SNOOZE_ENABLED, bool, False),
|
||||
SettingSpec(CONF_SNOOZE_DURATION_HOURS, int, DEFAULT_SNOOZE_DURATION_HOURS, int_range=(1, 168)),
|
||||
SettingSpec(CONF_WEEKLY_DIGEST_ENABLED, bool, False),
|
||||
SettingSpec(CONF_INSTALL_ASSIST_SENTENCES, bool, False),
|
||||
SettingSpec(CONF_WARRANTY_REMINDER_ENABLED, bool, False),
|
||||
SettingSpec(CONF_WARRANTY_REMINDER_DAYS, int, DEFAULT_WARRANTY_REMINDER_DAYS, int_range=(1, 365)),
|
||||
# List of days-before-due (bespoke int-list sanitiser in the WS handler).
|
||||
SettingSpec(CONF_REMINDER_LEAD_DAYS, list),
|
||||
SettingSpec(CONF_REMINDER_LEAD_DAYS, list, []),
|
||||
# Budget
|
||||
SettingSpec(CONF_BUDGET_MONTHLY, float, float_range=(0.0, 10_000_000.0)),
|
||||
SettingSpec(CONF_BUDGET_YEARLY, float, float_range=(0.0, 100_000_000.0)),
|
||||
SettingSpec(CONF_BUDGET_ALERTS_ENABLED, bool),
|
||||
SettingSpec(CONF_BUDGET_ALERT_THRESHOLD, int, int_range=(10, 100)),
|
||||
SettingSpec(CONF_BUDGET_CURRENCY, str, max_len=5),
|
||||
SettingSpec(CONF_BUDGET_MONTHLY, float, 0.0, float_range=(0.0, 10_000_000.0)),
|
||||
SettingSpec(CONF_BUDGET_YEARLY, float, 0.0, float_range=(0.0, 100_000_000.0)),
|
||||
SettingSpec(CONF_BUDGET_ALERTS_ENABLED, bool, False),
|
||||
SettingSpec(CONF_BUDGET_ALERT_THRESHOLD, int, 80, int_range=(10, 100)),
|
||||
SettingSpec(CONF_BUDGET_CURRENCY, str, DEFAULT_BUDGET_CURRENCY, max_len=5),
|
||||
SettingSpec(CONF_CURRENCY_DECIMALS, int, DEFAULT_CURRENCY_DECIMALS, int_range=(0, 3)),
|
||||
)
|
||||
|
||||
_SPEC_BY_KEY: dict[str, SettingSpec] = {s.key: s for s in SETTING_SPECS}
|
||||
|
||||
|
||||
def setting_default(key: str) -> Any:
|
||||
"""The default for a registered global setting — what an unset option
|
||||
means on every surface. Lists/dicts come back as copies so no caller can
|
||||
mutate the table. ``KeyError`` for an unregistered key (a typo fails loudly)."""
|
||||
default = _SPEC_BY_KEY[key].default
|
||||
return deepcopy(default) if isinstance(default, (list, dict)) else default
|
||||
|
||||
|
||||
def settings_defaults() -> dict[str, Any]:
|
||||
"""``{key: default}`` for every registered setting (fresh copies)."""
|
||||
return {s.key: setting_default(s.key) for s in SETTING_SPECS}
|
||||
|
||||
|
||||
# ─── Derived views (single source → the tables the handler used to hand-keep) ─
|
||||
|
||||
ALLOWED_SETTING_KEYS: dict[str, type | vol.Any] = {s.key: s.py_type for s in SETTING_SPECS}
|
||||
|
||||
@@ -1,60 +1,115 @@
|
||||
"""Shared task-status derivation from a coordinator data dict.
|
||||
"""The task-status ladder — ONE implementation for the model and the dict twin.
|
||||
|
||||
The per-task ``sensor`` and ``binary_sensor`` entities both need to recompute a
|
||||
task's status from the coordinator's plain data dict (e.g. right after a live
|
||||
trigger update) without rebuilding a full :class:`MaintenanceTask`. They used to
|
||||
carry byte-identical ``_compute_live_status`` copies; this is the single source.
|
||||
|
||||
This is the *dict* status ladder — a lightweight mirror of
|
||||
:pyattr:`MaintenanceTask.status`. It intentionally omits the model's sub-day
|
||||
``schedule_time`` refinement and the span-capped warning window (issue #58),
|
||||
which require the live ``Schedule`` object; those are applied by the next
|
||||
coordinator refresh. Keep the trigger / overdue / due-soon precedence here in
|
||||
sync with the model property.
|
||||
:pyattr:`MaintenanceTask.status` (the coordinator's per-refresh computation)
|
||||
and :func:`compute_status_from_task_dict` (the per-task ``sensor`` /
|
||||
``binary_sensor`` recomputation right after a live trigger update, from the
|
||||
coordinator's plain data dict) used to be two hand-kept ladders; the dict
|
||||
twin silently lacked the span-capped warning window (issue #58) and the
|
||||
sub-day ``schedule_time`` refinement, and neither knew that a disabled task
|
||||
reads OK (only the coordinator's short-circuit did). Both now delegate to
|
||||
:func:`compute_status`; the coordinator publishes the inputs the dict twin
|
||||
cannot derive itself (``_warning_days_effective``) alongside
|
||||
``_days_until_due`` / ``_trigger_active`` / ``_paused``.
|
||||
``tests/test_status_twin.py`` pins the two callers to each other.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from ..const import DEFAULT_WARNING_DAYS, MaintenanceStatus
|
||||
from .dates import parse_hhmm
|
||||
|
||||
|
||||
def effective_warning_days(warning_days: int, span_days: int | None) -> int:
|
||||
"""The warning window, capped at one interval measured in real days
|
||||
(issue #58: a 6-*month* task must not collapse a 14-day warning to
|
||||
``min(14, 6)``). ``span_days`` ``None``/0 = no cap (manual, one-off)."""
|
||||
return min(warning_days, span_days) if span_days else warning_days
|
||||
|
||||
|
||||
def is_past_schedule_time(schedule_time: str | None) -> bool:
|
||||
"""True iff a ``schedule_time`` is set AND the current local time is past it.
|
||||
|
||||
The sub-day refinement of the OVERDUE transition: when ``days_until_due``
|
||||
is exactly 0 the task counts as overdue once the configured HH:MM has
|
||||
passed (in HA's configured TZ). ``None``/blank keeps the historical
|
||||
"due at midnight" semantic; "HH:MM" (panel/WS) and "HH:MM:SS" (HA
|
||||
TimeSelector) parse alike.
|
||||
"""
|
||||
if not schedule_time:
|
||||
return False
|
||||
target = parse_hhmm(schedule_time)
|
||||
if target is None:
|
||||
return False
|
||||
return dt_util.now().time() >= target
|
||||
|
||||
|
||||
def compute_status(
|
||||
*,
|
||||
archived: bool,
|
||||
paused: bool = False,
|
||||
enabled: bool = True,
|
||||
days_until_due: int | None,
|
||||
trigger_active: bool,
|
||||
all_mode: bool,
|
||||
warning_days: int,
|
||||
past_schedule_time: bool,
|
||||
) -> MaintenanceStatus:
|
||||
"""The status ladder, precedence top to bottom.
|
||||
|
||||
* disabled → OK (a switched-off task is inert: no due, nothing to notify —
|
||||
the coordinator's short-circuit rule, folded in here);
|
||||
* archived → ARCHIVED (v2.10.0: retired but retained);
|
||||
* paused → PAUSED (v2.20, N3: the object's schedules are frozen);
|
||||
* trigger ∧/∨ safety interval: with the ``"all"`` combinator BOTH legs
|
||||
must be met — the trigger fired AND the due date reached (the interval
|
||||
is a minimum age, not a deadline); default ``"any"`` is whichever-first;
|
||||
* no schedule (``days_until_due`` None) → OK unless triggered;
|
||||
* past due, or due today past ``schedule_time`` → OVERDUE;
|
||||
* within the (span-capped) warning window → DUE_SOON; else OK.
|
||||
"""
|
||||
if not enabled:
|
||||
return MaintenanceStatus.OK
|
||||
if archived:
|
||||
return MaintenanceStatus.ARCHIVED
|
||||
if paused:
|
||||
return MaintenanceStatus.PAUSED
|
||||
|
||||
days = days_until_due
|
||||
time_met = days is None or days <= 0
|
||||
if trigger_active and (not all_mode or time_met):
|
||||
return MaintenanceStatus.TRIGGERED
|
||||
if days is None:
|
||||
return MaintenanceStatus.OK
|
||||
if all_mode and not trigger_active:
|
||||
# The elapsed interval alone never actions an "all" task.
|
||||
return MaintenanceStatus.OK
|
||||
if days < 0 or (days == 0 and past_schedule_time):
|
||||
return MaintenanceStatus.OVERDUE
|
||||
if days <= warning_days:
|
||||
return MaintenanceStatus.DUE_SOON
|
||||
return MaintenanceStatus.OK
|
||||
|
||||
|
||||
def compute_status_from_task_dict(task: dict[str, Any]) -> str:
|
||||
"""Compute task status from a coordinator data dict.
|
||||
|
||||
Mirrors :pyattr:`MaintenanceTask.status` for the archived / trigger /
|
||||
overdue / due-soon / ok ladder. ``_trigger_active`` and ``_days_until_due``
|
||||
are the coordinator-computed live fields.
|
||||
``_trigger_active``, ``_days_until_due``, ``_paused`` and
|
||||
``_warning_days_effective`` are the coordinator-computed live fields (the
|
||||
last falls back to the raw ``warning_days`` for payloads that predate it);
|
||||
``schedule_time`` is only present when the feature is enabled (the
|
||||
coordinator strips it otherwise), so the sub-day check is safe here.
|
||||
"""
|
||||
# Archived takes precedence over everything (v2.10.0) — see the model twin.
|
||||
if task.get("archived_at") is not None:
|
||||
return MaintenanceStatus.ARCHIVED
|
||||
# Seasonal pause (v2.20, N3): object-wide, injected by the coordinator.
|
||||
# Keeps a live trigger event from flipping a frozen task to TRIGGERED
|
||||
# between refreshes.
|
||||
if task.get("_paused"):
|
||||
return MaintenanceStatus.PAUSED
|
||||
days = task.get("_days_until_due")
|
||||
|
||||
# "all" combinator: trigger AND elapsed safety interval must both be met
|
||||
# before the task actions (model twin: MaintenanceTask.status).
|
||||
all_mode = (task.get("trigger_config") or {}).get("trigger_combinator") == "all"
|
||||
time_met = days is None or days <= 0
|
||||
trigger_active = task.get("_trigger_active", False)
|
||||
|
||||
if trigger_active and (not all_mode or time_met):
|
||||
return MaintenanceStatus.TRIGGERED
|
||||
|
||||
if days is None:
|
||||
return MaintenanceStatus.OK
|
||||
|
||||
if all_mode and not trigger_active:
|
||||
return MaintenanceStatus.OK
|
||||
|
||||
warning_days = task.get("warning_days", DEFAULT_WARNING_DAYS)
|
||||
if days < 0:
|
||||
return MaintenanceStatus.OVERDUE
|
||||
if days <= warning_days:
|
||||
return MaintenanceStatus.DUE_SOON
|
||||
return MaintenanceStatus.OK
|
||||
return compute_status(
|
||||
archived=task.get("archived_at") is not None,
|
||||
paused=bool(task.get("_paused")),
|
||||
enabled=task.get("enabled", True) is not False,
|
||||
days_until_due=task.get("_days_until_due"),
|
||||
trigger_active=bool(task.get("_trigger_active", False)),
|
||||
all_mode=(task.get("trigger_config") or {}).get("trigger_combinator") == "all",
|
||||
warning_days=task.get("_warning_days_effective", task.get("warning_days", DEFAULT_WARNING_DAYS)),
|
||||
past_schedule_time=is_past_schedule_time(task.get("schedule_time")),
|
||||
)
|
||||
Reference in new issue
Block a user