189 files

This commit is contained in:
Home Assistant Version Control committed 2026-09-08 18:05:32 +00:00
1 parent 79fefc0a7c
commit 303886f212
189 files changed
+21402 -3514

No files matched your search

@@ -11,7 +11,10 @@ Battery Notes exposes everything we need as ATTRIBUTES on its entities
``battery_low``, ``battery_low_threshold``, ``battery_last_replaced``. The
percentage sensor is the primary source; LOW-ONLY sources (a Matter lock with
just a battery-low binary, #121) are read from their ``…_battery_plus_low``
binary instead.
binary instead. A note whose device has NO battery level at all (D#162) gets
no ``battery_plus`` from Battery Notes — only the ``…_battery_type`` sensor,
the ``…_battery_last_replaced`` timestamp and the replaced button — and is
read from the type sensor as a forecast-only row (``no_sensor``).
Forecast (#114 + follow-up): the ~replacement date comes from the DISCHARGE
TREND where recorder data supports it (``async_trend_predictions`` — the
@@ -100,6 +103,47 @@ def _is_native_battery_sensor(state: Any) -> bool:
return not any(word in object_id for word in _HEURISTIC_EXCLUDE)
def _is_type_note(state: Any) -> bool:
"""Whether a sensor state is a Battery Notes ``…_battery_type`` sensor.
The type sensor carries ``battery_type`` + ``battery_quantity`` but NO
device class (``battery_plus`` and its low binary carry the same two
attributes WITH ``device_class: battery``). It exists for every note —
for a device without a level sensor it is the only trace (D#162).
"""
attrs = state.attributes
return attrs.get("device_class") is None and "battery_type" in attrs and "battery_quantity" in attrs
# Battery Notes unique_id suffixes — every entity of one note shares the
# subentry's unique_id plus one of these, so siblings resolve through the
# registry even after the user renamed the entity ids.
_NOTE_UID_SUFFIXES = (
"_battery_last_replaced",
"_battery_replaced_button",
"_battery_plus",
"_battery_type",
"_battery_low",
)
def note_sibling_entity(hass: HomeAssistant, entity_id: str, *, domain: str, uid_suffix: str) -> str | None:
"""The registry-resolved Battery Notes sibling of ``entity_id`` — e.g. the
``button`` / ``_battery_replaced_button`` of a type sensor — or None when
the entity has no Battery Notes registry entry (state-only) or the
sibling was never created. Callers fall back to the naming contract."""
from homeassistant.helpers import entity_registry as er
ent_reg = er.async_get(hass)
reg = ent_reg.async_get(entity_id)
if reg is None or reg.platform != "battery_notes" or not isinstance(reg.unique_id, str):
return None
for own in _NOTE_UID_SUFFIXES:
if reg.unique_id.endswith(own):
return ent_reg.async_get_entity_id(domain, "battery_notes", reg.unique_id[: -len(own)] + uid_suffix)
return None
def _native_snapshot_cache(hass: HomeAssistant) -> dict[str, dict[str, Any]]:
"""Runtime cache of last-known native battery readings (per entity_id)."""
from ..const import DOMAIN
@@ -156,6 +200,16 @@ class Battery:
# crosses first on the way down). One field feeds the trend regression,
# the sparkline threshold line and the level-bar colors alike.
low_threshold: float = float(NATIVE_LOW_PERCENT)
# D#162: a Battery Notes note WITHOUT a level sensor — only the type
# sensor, the last-replaced timestamp and the replaced button exist.
# Nothing can ever report "still fine": the forecast is the whole signal,
# and (option, default on) a passed forecast makes it ``low``.
no_sensor: bool = False
# D#162 follow-up: Battery Notes minted a replaced button for this note
# — the roster offers the per-row Replaced action. Decided in
# 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
@dataclass
@@ -204,7 +258,9 @@ def build_overview(
) -> BatteryOverview:
"""Aggregate batteries into the fleet view.
* ``low`` = reported low right now (Battery Notes' own threshold).
* ``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`).
* ``soon`` = NOT low yet but predicted to reach end-of-life within
``horizon_days`` (deterministic last_replaced + typical-lifetime forecast).
A battery already low is never double-counted into soon.
@@ -247,11 +303,18 @@ def build_overview(
overdue = days_raw is not None and days_raw < 0
days = max(0, days_raw) if days_raw is not None else None
if bat.low:
ov.low.append(_row(bat, t, None, rechargeable=rechargeable))
# A battery reported low has no meaningful forecast left to show —
# except a sensorless note (D#162), where the PASSED forecast IS
# 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)
else:
low_row = _row(bat, t, None, rechargeable=rechargeable)
ov.low.append(low_row)
if not rechargeable:
ov.needs_now[t] = ov.needs_now.get(t, 0) + bat.quantity
# A battery reported low has no meaningful forecast left to show.
ov.all.append({**_row(bat, t, None, rechargeable=rechargeable), "status": "low"})
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))
@@ -300,6 +363,11 @@ def _row(
# carries error bars and the sensor says fine — but the roster shows
# the discrepancy (common cause: a swap that was never recorded).
"forecast_overdue": forecast_overdue,
# D#162: no level source at all — the roster shows a "No sensor" chip
# instead of a level bar, and the replaced button is the row's action.
"no_sensor": bat.no_sensor,
"can_mark_replaced": bat.can_mark_replaced,
"last_replaced": bat.last_replaced.isoformat() if bat.last_replaced else None,
}
@@ -371,6 +439,21 @@ def fleet_track_self_charging(hass: HomeAssistant) -> bool:
return bool(_fleet_object(hass).get(BATTERY_FLEET_TRACK_SELF_CHARGING))
def fleet_due_without_sensor(hass: HomeAssistant) -> bool:
"""Whether a PASSED forecast on a sensorless note counts as due (D#162).
On (the default): a Battery Notes note that has no level sensor — only
its type, quantity and last-replaced date — goes ``low`` once its
predicted replacement date has passed, so the fleet task fires for it.
Nothing else ever could: there is no reading to say "still fine". Off:
such notes only ever forecast (B1 behaviour — soon, never low). Stored
as False to switch off; absent means on.
"""
from ..const import BATTERY_FLEET_DUE_WITHOUT_SENSOR
return _fleet_object(hass).get(BATTERY_FLEET_DUE_WITHOUT_SENSOR) is not False
def _is_self_charging(hass: HomeAssistant, device_id: str | None) -> bool:
"""Whether a device recharges itself — its battery is never REPLACED.
@@ -436,8 +519,18 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
last seen LOW that goes unavailable is retained from a runtime
snapshot for ``_NATIVE_RETENTION`` (the Battery Notes path gets this
for free via its retained ``battery_low`` attribute).
* **Sensorless notes** (D#162, pass 1b): a device with no battery level
at all gets no ``battery_plus`` from Battery Notes — only the
``…_battery_type`` sensor (type/quantity), the
``…_battery_last_replaced`` timestamp and the replaced button. The
type sensor becomes a forecast-only row (``no_sensor``, never
offline, no level): last-replaced + the type's typical lifetime
predicts the swap, and with ``fleet_due_without_sensor`` on a PASSED
forecast makes the row ``low`` — the only way such a battery can ever
reach the task. Devices that have any ``battery_plus`` (kept, dropped
or excluded) or a native row are skipped: the real reading wins.
* Manually excluded entity_ids (fleet detail → exclude) are dropped from
BOTH passes.
ALL passes.
* Manual includes (#135) act DEVICE-wide: whichever of a device's battery
entities the user picked, the self-charging filter is lifted for the
whole device in both passes — so the richest source (a Battery Notes
@@ -489,6 +582,11 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
# and taking it too would put every battery in the roster twice — and let
# an exclusion set on the sensor row resurrect through the binary.
note_sensor_ids: set[str] = set()
# Pass 1b dedupe: EVERY battery_plus entity (kept, dropped or excluded)
# and its device — a device that has a real reading never gets a
# forecast-only row on top.
note_binary_ids: set[str] = set()
note_devices_all: set[str] = set()
for domain, binary_pass in (("sensor", False), ("binary_sensor", True)):
for state in hass.states.async_all(domain):
attrs = state.attributes
@@ -499,8 +597,12 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
# kept, dropped or excluded: its low binary describes the same
# battery and must never become a second (or resurrected) row.
note_sensor_ids.add(state.entity_id)
else:
note_binary_ids.add(state.entity_id)
reg = ent_reg.async_get(state.entity_id)
dev_id = reg.device_id if reg else None
if dev_id:
note_devices_all.add(dev_id)
if binary_pass:
if dev_id and dev_id in covered_devices:
continue
@@ -682,11 +784,108 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
last_replaced=None,
available=available,
source="native",
# The household floor decided ``low`` above — the sparkline
# line, the level-bar colours and the trend regression must
# cross at the same level, not at the 20 % class default
# (bug review 2026-09-04).
low_threshold=floor,
)
)
# ── Pass 1b: Battery Notes notes WITHOUT a level sensor (D#162) ────────
# A device that exposes no battery percentage (maisun's 30 Xiaomi/Aqara
# sensors) gets NO battery_plus from Battery Notes — only the diagnostic
# ``…_battery_type`` sensor, the ``…_battery_last_replaced`` timestamp
# and the replaced button. Runs AFTER the other passes so any real
# 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()
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):
continue
eid = state.entity_id
attrs = state.attributes
reg = ent_reg.async_get(eid)
dev_id = reg.device_id if reg else None
if dev_id and (dev_id in note_devices_all or dev_id in covered_devices or dev_id in native_devices):
continue
# State-only entities have no registry entry — Battery Notes' naming
# contract (``sensor.X_battery_type`` ↔ ``sensor.X_battery_plus`` ↔
# ``binary_sensor.X_battery_plus_low``) is the fallback dedupe.
object_id = eid.split(".", 1)[1]
base = object_id[: -len("_battery_type")] if object_id.endswith("_battery_type") else None
if base and (f"sensor.{base}_battery_plus" in note_sensor_ids or f"binary_sensor.{base}_battery_plus_low" in note_binary_ids):
continue
if eid in excluded:
continue
if not track_self and eid not in included and dev_id not in included_devices and _self_charging(dev_id):
continue
battery_type = str(attrs.get("battery_type") or "").strip()
if not battery_type:
continue
# The replacement date lives on the sibling timestamp sensor (its
# state is the ISO timestamp) — registry first, naming second. It may
# be disabled in Battery Notes: then the row has no forecast at all.
last_eid = note_sibling_entity(hass, eid, domain="sensor", uid_suffix="_battery_last_replaced")
if last_eid is None and base:
last_eid = f"sensor.{base}_battery_last_replaced"
last_state = hass.states.get(last_eid) if last_eid else None
last_replaced = (
_parse_last_replaced(last_state.state) if last_state and last_state.state not in _NO_READING else None
)
# The type sensor is named "<device> Battery type" — the device name
# is the registry's; without a registry entry strip the entity's own
# name off the friendly name (English fallback for state-only notes).
name = None
if dev_id and (dev := dev_reg.async_get(dev_id)):
name = dev.name_by_user or dev.name
friendly = str(attrs.get("friendly_name") or "")
if not name and friendly:
own_name = (reg.name or reg.original_name) if reg else None
if own_name and friendly.endswith(own_name):
name = friendly[: -len(own_name)].strip()
else:
name = re.sub(r"\s+battery\s+type$", "", friendly, flags=re.IGNORECASE).strip()
if not name:
name = (base or object_id).replace("_", " ").strip().title()
bat = Battery(
entity_id=eid,
device_name=name,
battery_type=battery_type,
quantity=int(attrs.get("battery_quantity") or 1),
low=False,
level=None,
last_replaced=last_replaced,
available=True,
source="battery_notes",
low_threshold=floor,
no_sensor=True,
)
# 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)
bat.low = pred is not None and pred < today
out.append(bat)
for bat in out:
if bat.source == "battery_notes":
bat.can_mark_replaced = has_replaced_button(hass, bat.entity_id)
return out
def has_replaced_button(hass: HomeAssistant, entity_id: str) -> bool:
"""Whether Battery Notes minted a replaced button for this note's row
(naming contract first, registry sibling second — the same lookup
:func:`battery_fleet_setup.async_mark_replaced` presses)."""
from .battery_fleet_setup import replaced_button_for
if hass.states.get(replaced_button_for(entity_id)) is not None:
return True
sibling = note_sibling_entity(hass, entity_id, domain="button", uid_suffix="_battery_replaced_button")
return sibling is not None and hass.states.get(sibling) is not None
def battery_notes_summary(hass: HomeAssistant) -> dict[str, Any] | None:
"""What Battery Notes currently reports, for the Settings hint (#146).
@@ -746,19 +945,22 @@ def has_battery_notes(hass: HomeAssistant) -> bool:
"""Whether the Battery Notes integration is present (any battery_plus).
Binaries count too (#121): an install whose only noted devices are
low-only sources has no ``battery_plus`` sensor at all.
low-only sources has no ``battery_plus`` sensor at all — and so does a
fleet of sensorless notes (D#162), which only have type sensors.
"""
for domain in ("sensor", "binary_sensor"):
for state in hass.states.async_all(domain):
a = state.attributes
if a.get("device_class") == "battery" and "battery_type" in a:
return True
if domain == "sensor" and _is_type_note(state):
return True
return False
def has_batteries(hass: HomeAssistant) -> bool:
"""Whether ANY battery is trackable — Battery Notes OR native. Gates setup."""
if any(_is_native_battery_sensor(s) for s in hass.states.async_all("sensor")):
if any(_is_native_battery_sensor(s) or _is_type_note(s) for s in hass.states.async_all("sensor")):
return True
return any(s.attributes.get("device_class") == "battery" for s in hass.states.async_all("binary_sensor"))
@@ -940,8 +1142,8 @@ async def async_level_history(hass: HomeAssistant, batteries: list[Battery]) ->
out: dict[str, dict[str, Any]] = {}
for bat in batteries:
if bat.level is None and not bat.low:
continue # low-only binaries have no level series to draw
if bat.no_sensor or (bat.level is None and not bat.low):
continue # low-only binaries / sensorless notes have no level series to draw
cached = cache.get(bat.entity_id)
if cached is not None and now - cached[0] < _HISTORY_CACHE_TTL:
points = cached[1]
@@ -1008,6 +1210,7 @@ __all__ = [
"build_overview",
"compute_overview",
"discover_battery_types",
"fleet_due_without_sensor",
"fleet_excluded_entities",
"fleet_included_entities",
"fleet_track_self_charging",
@@ -1015,5 +1218,6 @@ __all__ = [
"has_battery_notes",
"is_rechargeable_type",
"lifetime_months",
"note_sibling_entity",
"read_batteries",
]
@@ -11,6 +11,7 @@ threshold trigger. No per-battery task.
from __future__ import annotations
import logging
import re
from collections.abc import Callable
from typing import Any
from uuid import uuid4
@@ -24,13 +25,15 @@ from ..const import (
BATTERY_FLEET_EXCLUDED,
BATTERY_FLEET_INCLUDED,
BATTERY_FLEET_OBJECT_FLAG,
BATTERY_FLEET_REMOVED_PARTS,
BATTERY_FLEET_TASK_FLAG,
CONF_OBJECT,
CONF_PARTS,
CONF_TASK_CONSUMES_PARTS,
CONF_TASKS,
DOMAIN,
)
from .battery_fleet import _norm_type, discover_battery_types, lifetime_months, read_batteries
from .battery_fleet import _norm_type, discover_battery_types, lifetime_months, note_sibling_entity, read_batteries
from .trigger_fallback import threshold_limits_overlap
_LOGGER = logging.getLogger(__name__)
@@ -77,6 +80,10 @@ async def async_setup_battery_fleet(hass: HomeAssistant, language: str | None =
existing = find_fleet_entry(hass)
if existing is not None:
# 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):
_mutate_fleet_object(hass, lambda obj: obj.pop(BATTERY_FLEET_REMOVED_PARTS, None))
added_pids = _reconcile_type_parts(hass, existing, types, lang)
# Track stock at 0 for the parts just added — the CREATE path below
# does, and a part left untracked (stock None) shows no stock line
@@ -318,7 +325,12 @@ def replaced_button_for(battery_plus_entity_id: str) -> str:
.replace("_battery_plus_low", "_battery_replaced")
.replace("_battery_plus", "_battery_replaced")
)
return battery_plus_entity_id.replace("sensor.", "button.", 1).replace("_battery_plus", "_battery_replaced")
# D#162 sensorless notes are read from sensor.<x>_battery_type — same button.
return (
battery_plus_entity_id.replace("sensor.", "button.", 1)
.replace("_battery_plus", "_battery_replaced")
.replace("_battery_type", "_battery_replaced")
)
async def async_mark_replaced(hass: HomeAssistant, entity_ids: list[str] | None = None) -> dict[str, Any]:
@@ -341,6 +353,10 @@ async def async_mark_replaced(hass: HomeAssistant, entity_ids: list[str] | None
if bat is None:
continue
button = replaced_button_for(eid)
if hass.states.get(button) is 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
@@ -443,6 +459,17 @@ def set_track_self_charging(hass: HomeAssistant, enabled: bool) -> bool:
return _mutate_fleet_object(hass, lambda obj: obj.__setitem__(BATTERY_FLEET_TRACK_SELF_CHARGING, bool(enabled)))
def set_due_without_sensor(hass: HomeAssistant, enabled: bool) -> bool:
"""Persist the fleet-wide due-without-sensor option (D#162).
On by default; stored as False to switch off. Returns False when no
fleet exists yet.
"""
from ..const import BATTERY_FLEET_DUE_WITHOUT_SENSOR
return _mutate_fleet_object(hass, lambda obj: obj.__setitem__(BATTERY_FLEET_DUE_WITHOUT_SENSOR, bool(enabled)))
def find_fleet_task(entry: ConfigEntry) -> tuple[str, dict[str, Any]] | None:
"""The flagged fleet task (id, data) on the fleet entry, or None."""
for task_id, task_data in (entry.data.get(CONF_TASKS) or {}).items():
@@ -580,15 +607,7 @@ async def reconcile_fleet_parts_at_start(hass: HomeAssistant, entry: ConfigEntry
legacy = parts.get("batt_unknown")
if legacy is not None:
stock = store.get_part_stock("batt_unknown") if store is not None else None
untouched = (
not stock
and not legacy.get("product_url")
and not legacy.get("vendor")
and not legacy.get("mpn")
and not legacy.get("gtin")
and not legacy.get("auto_buy_task")
)
if untouched:
if _legacy_unknown_untouched(entry, legacy, stock):
parts.pop("batt_unknown")
new_data = dict(entry.data)
new_data[CONF_PARTS] = parts
@@ -612,10 +631,14 @@ async def reconcile_fleet_parts_at_start(hass: HomeAssistant, entry: ConfigEntry
uid = reg_entry.unique_id or ""
if "_part_" not in uid:
continue
pid = uid.split("_part_", 1)[1]
if pid and pid not in parts:
ent_reg.async_remove(reg_entry.entity_id)
orphans_removed += 1
# Match the way the sensor mints it (``…_{object_slug}_part_{pid}``)
# from the END — splitting at the first "_part_" mis-parsed a slug
# that itself contains "_part_" and swept the live sensor (bug
# review 2026-09-04).
if any(uid.endswith(f"_part_{pid}") for pid in parts):
continue
ent_reg.async_remove(reg_entry.entity_id)
orphans_removed += 1
if store is not None and (added or pruned):
for pid in added:
@@ -625,6 +648,45 @@ async def reconcile_fleet_parts_at_start(hass: HomeAssistant, entry: ConfigEntry
# The notes text v2.38 minted before the seeded texts were localized.
_LEGACY_UNKNOWN_NOTES = re.compile(r"Typical service life ~\d+ months( \(editorial\))?\.")
def _legacy_unknown_untouched(entry: ConfigEntry, legacy: dict[str, Any], stock: Any) -> bool:
"""Whether the legacy "UNKNOWN battery" part still looks exactly the way
older versions minted it — the only state the start-up prune may remove.
Bug review 2026-09-04: the v2.70 check looked at stock, product data and
auto-buy only. A part the user had RENAMED, annotated, priced, re-
thresholded, linked to a document or wired into a task's consumption
was still "untouched" and vanished silently on the next start.
"""
if stock:
return False
if any(
legacy.get(key)
for key in ("product_url", "vendor", "mpn", "gtin", "auto_buy_task", "cost", "doc_id", "storage_location")
):
return False
if _extract_placeholder(str(legacy.get("name") or ""), "{type} battery", "type") is None:
return False
notes = str(legacy.get("notes") or "")
if (
notes
and _extract_placeholder(notes, "Typical service life ~{months} months.", "months") is None
and not _LEGACY_UNKNOWN_NOTES.fullmatch(notes)
):
return False
threshold = legacy.get("reorder_threshold")
if not (isinstance(threshold, (int, float)) and threshold >= 2 and legacy.get("restock_quantity") == threshold * 2):
return False
for task in (entry.data.get(CONF_TASKS) or {}).values():
links = task.get(CONF_TASK_CONSUMES_PARTS)
if isinstance(links, list) and any(isinstance(x, dict) and x.get("part_id") == "batt_unknown" for x in links):
return False
return True
def _reconcile_type_parts(hass: HomeAssistant, entry: ConfigEntry, types: dict[str, int], lang: str) -> list[str]:
"""Add parts for battery types newly seen since setup. Returns added ids
(the caller initializes their stock, mirroring the create path)."""
@@ -632,10 +694,11 @@ def _reconcile_type_parts(hass: HomeAssistant, entry: ConfigEntry, types: dict[s
parts = dict(entry.data.get(CONF_PARTS) or {})
existing_ids = set(parts)
removed = set(entry.data.get(CONF_OBJECT, {}).get(BATTERY_FLEET_REMOVED_PARTS) or [])
added: list[str] = []
for btype, total_qty in types.items():
pid = f"batt_{btype.lower()}"
if pid not in existing_ids:
if pid not in existing_ids and pid not in removed:
parts[pid] = normalize_part(_type_part(btype, total_qty, lang))
added.append(pid)
if added:
@@ -22,6 +22,7 @@ from ..const import (
from .aggregate import merged_tasks
from .dates import INTERVAL_UNITS
from .global_options import get_default_warning_days
from .reading_slots import parse_reading_slots_text, reading_slots_text
from .schedule import read_legacy_fields
_LOGGER = logging.getLogger(__name__)
@@ -50,6 +51,9 @@ _COLUMNS = [
"interval_anchor",
"schedule_time",
"reading_unit",
# #161 phase 2: reading slots, one "Name | Unit" per line (ids are
# regenerated on import — a CSV round-trip creates new tasks anyway).
"readings",
"warning_days",
"last_performed",
"notes",
@@ -124,6 +128,7 @@ def export_objects_csv(hass: HomeAssistant, entry_ids: set[str] | None = None) -
"interval_anchor": sched["interval_anchor"],
"schedule_time": tdata.get("schedule_time", ""),
"reading_unit": _csv_safe(tdata.get("reading_unit", "")),
"readings": "\n".join(_csv_safe(line) for line in reading_slots_text(tdata.get("readings")).splitlines()),
"warning_days": tdata.get("warning_days", DEFAULT_WARNING_DAYS),
"last_performed": tdata.get("last_performed", ""),
"notes": _csv_safe(tdata.get("notes", "")),
@@ -287,6 +292,11 @@ def import_objects_csv(
reading_unit = (row.get("reading_unit") or "").strip()
if reading_unit:
task_data["reading_unit"] = reading_unit[:32]
readings_raw = row.get("readings") or ""
if readings_raw.strip():
slots = parse_reading_slots_text(readings_raw)
if slots:
task_data["readings"] = slots
last_performed = (row.get("last_performed") or "").strip()
if last_performed:
@@ -249,6 +249,7 @@ async def import_documents_archive(hass: HomeAssistant, data: bytes) -> dict[str
_, wrote_new = await hass.async_add_executor_job(store._store_blob_sync, content)
if wrote_new:
written += 1
store.notify_blob_added(digest)
# 2) Re-attach metadata to the matching object (id, then name).
ids, by_name = _object_name_map(hass)
@@ -18,7 +18,7 @@ import hashlib
import logging
import os
from pathlib import Path
from typing import Any
from typing import TYPE_CHECKING, Any
from uuid import uuid4
from homeassistant.core import HomeAssistant
@@ -28,6 +28,9 @@ from homeassistant.util import dt as dt_util
from ..const import DOMAIN, MAX_DOCS_PER_OBJECT, SIGNAL_DOCUMENTS_UPDATED
if TYPE_CHECKING:
from .document_text import DocumentTextIndex
_LOGGER = logging.getLogger(__name__)
DOC_STORE_VERSION = 1
@@ -60,6 +63,10 @@ class DocumentStore:
self.hass = hass
self._store: Store[dict[str, Any]] = Store(hass, DOC_STORE_VERSION, DOC_STORE_KEY)
self._data: dict[str, Any] = {"documents": {}, "blobs": {}}
# The full-text index (#171) follows the blob lifecycle: told about
# every new blob, told when the last reference goes. Optional so the
# store stays usable on its own (tests, tooling).
self.text_index: DocumentTextIndex | None = None
# ------------------------------------------------------------------
# Paths
@@ -214,6 +221,7 @@ class DocumentStore:
await self._async_save()
if not wrote_new and not deduped:
_LOGGER.debug("Adopted pre-existing blob %s into registry", digest[:12])
self.notify_blob_added(digest)
return {
"id": doc_id,
"deduped": deduped,
@@ -221,6 +229,12 @@ class DocumentStore:
**doc,
}
def notify_blob_added(self, digest: str) -> None:
"""A blob is (now) referenced — let the text index extract it in the
background. Idempotent: an already-extracted blob is a no-op."""
if self.text_index is not None:
self.text_index.schedule(digest)
def _store_blob_sync(self, content: bytes) -> tuple[str, bool]:
"""Hash content and write the blob if absent. Returns (digest, wrote_new)."""
digest = hashlib.sha256(content).hexdigest()
@@ -263,6 +277,7 @@ class DocumentStore:
docs: list[dict[str, Any]],
task_id_map: dict[str, str] | None = None,
part_id_map: dict[str, str] | None = None,
id_map: dict[str, str] | None = None,
) -> int:
"""Recreate document metadata for an imported object (P6).
@@ -272,9 +287,17 @@ class DocumentStore:
absent and the hygiene scan flags the doc as dangling. ``task_ids`` /
``part_ids`` are remapped through their old→new id maps so a doc's
task and spare-part links survive the import; ids with no mapping are
dropped. Returns the number created.
dropped. ``id_map`` (if given) is filled with old→new document ids
for every doc whose export carried an ``id`` — the importer uses it
to re-point completion photos and part ``doc_id`` links. Returns the
number created.
"""
def _remember(meta: dict[str, Any], new_id: str) -> None:
old_id = meta.get("id")
if id_map is not None and isinstance(old_id, str) and old_id:
id_map[old_id] = new_id
def _remap(meta: dict[str, Any]) -> list[str]:
if not task_id_map:
return []
@@ -298,7 +321,9 @@ class DocumentStore:
# frontend would later window.open (matches ws_documents_add_link).
if not isinstance(url, str) or not url.lower().startswith(("http://", "https://")):
continue
self.documents[uuid4().hex] = {
new_id = uuid4().hex
_remember(meta, new_id)
self.documents[new_id] = {
"object_id": object_id,
"kind": KIND_WEBLINK,
"url": url,
@@ -320,7 +345,9 @@ class DocumentStore:
blob = {"size": size, "mime": mime, "refcount": 0}
self.blobs[digest] = blob
blob["refcount"] += 1
self.documents[uuid4().hex] = {
new_id = uuid4().hex
_remember(meta, new_id)
self.documents[new_id] = {
"object_id": object_id,
"kind": KIND_FILE,
"hash": digest,
@@ -464,15 +491,24 @@ class DocumentStore:
if blob["refcount"] <= 0:
size = int(blob.get("size", 0))
self.blobs.pop(digest, None)
if self.text_index is not None:
await self.text_index.async_forget(digest)
await self.hass.async_add_executor_job(self._delete_blob_sync, digest)
return size
return 0
def _delete_blob_sync(self, digest: str) -> None:
"""Delete a blob file and its extracted-text sidecar (executor)."""
from .document_text import text_sidecar_path
try:
self.blob_path(digest).unlink(missing_ok=True)
except OSError:
_LOGGER.warning("Could not delete blob %s", digest[:12])
try:
text_sidecar_path(self.hass, digest).unlink(missing_ok=True)
except (OSError, ValueError):
_LOGGER.debug("Could not delete text sidecar %s", digest[:12])
# ------------------------------------------------------------------
# Storage summary (backs the sensor + the panel overview)
@@ -30,6 +30,7 @@ from ..const import (
CONF_QUIET_HOURS_END,
CONF_QUIET_HOURS_START,
CONF_SNOOZE_DURATION_HOURS,
CONF_TASKS,
DEFAULT_MAX_NOTIFICATIONS_PER_DAY,
DEFAULT_SNOOZE_DURATION_HOURS,
DOMAIN,
@@ -37,6 +38,16 @@ from ..const import (
)
from .global_options import get_global_options
from .i18n import normalize_language
from .notify_hooks import (
KIND_BUDGET,
KIND_BUNDLE,
KIND_DIGEST,
KIND_LEAD_TIME,
KIND_STATUS,
KIND_WARRANTY,
async_emit_and_dispatch,
notification_context,
)
_LOGGER = logging.getLogger(__name__)
@@ -692,7 +703,7 @@ async def async_dispatch_notify(
url = (service_data.get("data") or {}).get("url")
msg = service_data.get("message", "")
if url and "](" not in msg:
link_text = _notif_t("open_task_link", getattr(hass.config, "language", None) or "en")
link_text = _notif_t("open_task_link", normalize_language(hass))
service_data = {**service_data, "message": f"{msg}\n\n[{link_text}]({url})"}
if hass.services.has_service(domain, name):
await hass.services.async_call(domain, name, service_data, blocking=blocking)
@@ -767,6 +778,7 @@ class NotificationManager:
message: str,
entry_id: str,
task_id: str,
context: Mapping[str, Any],
) -> bool:
"""Resolve targets (per-user services, else the global service) and
fan the notification out; True when at least one send succeeded.
@@ -803,6 +815,7 @@ class NotificationManager:
message=message,
entry_id=entry_id,
task_id=task_id,
context=context,
):
success = True
return success
@@ -812,6 +825,16 @@ class NotificationManager:
"""Get the HA UI language as a 2-letter table key."""
return normalize_language(self.hass)
def _skip_allowed(self, entry_id: str, task_id: str) -> bool:
"""#150: a skip-locked task gets no "Skip" action button — the tap
could only fail in the action handler (bug review 2026-09-04).
Unknown entries/tasks keep the button (nothing to check against)."""
entry = self.hass.config_entries.async_get_entry(entry_id)
if entry is None:
return True
task = (entry.data.get(CONF_TASKS) or {}).get(task_id) or {}
return task.get("allow_skip") is not False
@property
def enabled(self) -> bool:
"""Check if notifications are globally enabled."""
@@ -1034,12 +1057,25 @@ class NotificationManager:
lang = self._lang
title, message = self._build_message(new_status, lang, task_name, object_name, days_until_due, next_due)
context = notification_context(
self.hass,
KIND_STATUS,
status=new_status,
entry_id=entry_id,
task_id=task_id,
task_name=task_name,
object_name=object_name,
days_until_due=days_until_due,
next_due=next_due,
responsible_user_id=responsible_user_id,
)
if not await self._resolve_and_send(
responsible_user_id,
title=title,
message=message,
entry_id=entry_id,
task_id=task_id,
context=context,
):
return
@@ -1111,6 +1147,7 @@ class NotificationManager:
message: str,
entry_id: str,
task_id: str,
context: Mapping[str, Any] | None = None,
) -> bool:
"""Send notification via specific service, optionally with action buttons.
@@ -1136,7 +1173,7 @@ class NotificationManager:
"title": f"\u2705 {_notif_t('action_complete', lang)}",
}
)
if options.get(CONF_ACTION_SKIP_ENABLED, False):
if options.get(CONF_ACTION_SKIP_ENABLED, False) and self._skip_allowed(entry_id, task_id):
actions.append(
{
"action": f"MS_SKIP_{entry_id}_{task_id}",
@@ -1160,8 +1197,10 @@ class NotificationManager:
if actions:
service_data["data"]["actions"] = actions[:3] # Android supports max 3
if context is None:
context = notification_context(self.hass, KIND_STATUS, entry_id=entry_id, task_id=task_id)
try:
return await async_dispatch_notify(self.hass, service, service_data)
return await async_emit_and_dispatch(self.hass, service, service_data, context)
except (HomeAssistantError, ValueError, TypeError):
_LOGGER.exception("Failed to send notification to %s", service)
return False
@@ -1216,8 +1255,18 @@ class NotificationManager:
url=f"/maintenance-supporter?entry_id={entry_id}",
)
context = notification_context(
self.hass,
KIND_BUNDLE,
entry_id=entry_id,
object_name=object_name,
tasks=[
{"task_id": t.get("task_id"), "task_name": t.get("task_name"), "status": t.get("status")}
for t in tasks
],
)
try:
if await async_dispatch_notify(self.hass, self.notify_service, service_data):
if await async_emit_and_dispatch(self.hass, self.notify_service, service_data, context):
self._last_notified[bundle_key] = dt_util.now()
self._daily_count += 1
_LOGGER.debug("Bundled notification sent: %s - %s", title, message)
@@ -1241,8 +1290,9 @@ class NotificationManager:
_notif_t("digest_message", lang, overdue=str(overdue), due_soon=str(due_soon)),
tag="maintenance_weekly_digest",
)
context = notification_context(self.hass, KIND_DIGEST, overdue=overdue, due_soon=due_soon)
try:
await async_dispatch_notify(self.hass, self.notify_service, service_data)
await async_emit_and_dispatch(self.hass, self.notify_service, service_data, context)
_LOGGER.debug("Weekly digest sent: %s overdue, %s due soon", overdue, due_soon)
except (HomeAssistantError, ValueError, TypeError):
_LOGGER.exception("Failed to send weekly digest")
@@ -1267,8 +1317,9 @@ class NotificationManager:
),
tag="maintenance_warranty_reminder",
)
context = notification_context(self.hass, KIND_WARRANTY, names=list(names), days=days)
try:
await async_dispatch_notify(self.hass, self.notify_service, service_data)
await async_emit_and_dispatch(self.hass, self.notify_service, service_data, context)
_LOGGER.debug("Warranty reminder sent: %s object(s)", len(names))
except (HomeAssistantError, ValueError, TypeError):
_LOGGER.exception("Failed to send warranty reminder")
@@ -1319,12 +1370,25 @@ class NotificationManager:
due=next_due if next_due is not None else "?",
)
context = notification_context(
self.hass,
KIND_LEAD_TIME,
status="due_soon",
entry_id=entry_id,
task_id=task_id,
task_name=task_name,
object_name=object_name,
days_until_due=days,
next_due=next_due,
responsible_user_id=responsible_user_id,
)
if await self._resolve_and_send(
responsible_user_id,
title=title,
message=message,
entry_id=entry_id,
task_id=task_id,
context=context,
):
self._daily_count += 1
_LOGGER.debug("Lead reminder sent: %s due in %s day(s)", task_name, days)
@@ -1365,8 +1429,9 @@ class NotificationManager:
service_data = _service_payload(title, message, tag=f"maintenance_budget_{period}")
context = notification_context(self.hass, KIND_BUDGET, period=period, spent=spent, budget=budget, percent=pct)
try:
if await async_dispatch_notify(self.hass, self.notify_service, service_data):
if await async_emit_and_dispatch(self.hass, self.notify_service, service_data, context):
self._last_notified[budget_key] = dt_util.now()
self._daily_count += 1
_LOGGER.debug("Budget alert sent: %s - %s", title, message)
@@ -116,9 +116,19 @@ _FRESH_COPY_STRIP_KEYS = (
"adaptive_config",
"archived_at",
"archived_reason",
# #170: a copy is a new task — it gets its own reference number.
"ref_no",
)
def strip_object_reference(obj: dict[str, Any]) -> dict[str, Any]:
"""A copied / successor object is a new object (#170): drop the reference
number and the task counter so the setup pass numbers it afresh."""
obj.pop("ref_no", None)
obj.pop("next_task_ref", None)
return obj
def strip_task_runtime_state(task: dict[str, Any]) -> dict[str, Any]:
"""Remove unique/dynamic state from a task dict copy, in place.
@@ -164,6 +174,12 @@ def cap_task_fields(task_data: dict[str, Any]) -> dict[str, Any]:
lo, hi = EARLIEST_COMPLETION_RANGE
task_data["earliest_completion_days"] = max(lo, min(ecd, hi))
# #170: a reference number is a positive int or nothing — junk from a
# hand-edited backup must not poison the counters.
ref = task_data.get("ref_no")
if ref is not None and (not isinstance(ref, int) or isinstance(ref, bool) or ref < 1):
task_data.pop("ref_no", None)
cl = task_data.get("checklist")
if cl is not None:
if not isinstance(cl, list):
@@ -175,6 +191,17 @@ def cap_task_fields(task_data: dict[str, Any]) -> dict[str, Any]:
cleaned = [c for c in cleaned if c]
task_data["checklist"] = cleaned[:MAX_CHECKLIST_ITEMS]
# #161 phase 2: reading slots — validated by the shared helper; an
# empty/invalid list simply means "single value" and is dropped.
rs = task_data.get("readings")
if rs is not None:
from .reading_slots import sanitize_reading_slots
slots = sanitize_reading_slots(rs)
if slots:
task_data["readings"] = slots
else:
task_data.pop("readings", None)
lb = task_data.get("labels")
if lb is not None:
task_data["labels"] = sanitize_labels(lb)
@@ -46,12 +46,15 @@ from ..const import (
CONF_DISABLED_TEMPLATE_IDS,
CONF_INSTALL_ASSIST_SENTENCES,
CONF_MAX_NOTIFICATIONS_PER_DAY,
CONF_MEMBER_DISPLAY,
CONF_NOTIFICATION_BUNDLE_THRESHOLD,
CONF_NOTIFICATION_BUNDLING_ENABLED,
CONF_NOTIFICATION_TITLE_STYLE,
CONF_NOTIFICATIONS_ENABLED,
CONF_NOTIFY_DUE_SOON_ENABLED,
CONF_NOTIFY_DUE_SOON_INTERVAL,
CONF_NOTIFY_EVENT_ONLY,
CONF_NOTIFY_EXTRA_DATA,
CONF_NOTIFY_OVERDUE_ENABLED,
CONF_NOTIFY_OVERDUE_INTERVAL,
CONF_NOTIFY_SCOPE_VIEW_ID,
@@ -73,6 +76,7 @@ from ..const import (
CONF_WARRANTY_REMINDER_DAYS,
CONF_WARRANTY_REMINDER_ENABLED,
CONF_WEEKLY_DIGEST_ENABLED,
MAX_NOTIFY_EXTRA_DATA_LENGTH,
)
@@ -125,6 +129,8 @@ SETTING_SPECS: tuple[SettingSpec, ...] = (
SettingSpec(CONF_ADMIN_PANEL_USER_IDS, list),
SettingSpec(CONF_OPERATOR_WRITE_ENABLED, bool),
SettingSpec(CONF_OBJECTS_TABLE_COLUMNS, list),
# #169 follow-up: member avatars (bespoke palette/initials sanitiser)
SettingSpec(CONF_MEMBER_DISPLAY, dict),
# v2.21: hidden template ids (bespoke known-id sanitiser in the handler)
SettingSpec(CONF_DISABLED_TEMPLATE_IDS, list),
# Archive automation
@@ -148,6 +154,9 @@ SETTING_SPECS: tuple[SettingSpec, ...] = (
# 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),
# #165: your own notification rule
SettingSpec(CONF_NOTIFY_EVENT_ONLY, bool),
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),
@@ -58,6 +58,25 @@ def _catalog_name_variants() -> set[str]:
return variants
def _matches_catalog_key(entry: er.RegistryEntry, key: str, catalog_keys: set[str]) -> bool:
"""``_entity_matches`` where a LONGER key of the same catalog that ends
in ``key`` owns the entity.
The entity_id-suffix fallback made dreame's ``secondary_filter_left``
sensor a second source of "Replace Filter" (``…_filter_left``) next to
its own "Replace Secondary Filter" duty — the secondary filter's wear
then fired the main-filter task (bug review 2026-09-04). An exact
translation_key match is the integration's own word and needs no guard.
"""
if entry.translation_key == key:
return True
if not _entity_matches(entry, key):
return False
return not any(
other != key and other.endswith(f"_{key}") and _entity_matches(entry, other) for other in catalog_keys
)
def discover_integration_setups(hass: HomeAssistant) -> list[dict[str, Any]]:
"""Devices of catalogued integrations with their matchable task wiring.
@@ -107,6 +126,7 @@ def discover_integration_setups(hass: HomeAssistant) -> list[dict[str, Any]]:
if device is not None and getattr(device, "parent_device_id", None) is None
else ""
).lower()
catalog_keys = {key for s in catalog.tasks for key in s.keys}
for sig in catalog.tasks:
# Device-type gates: registry model substring and/or a
# type-identifying sibling entity (watched siblings still count —
@@ -127,7 +147,7 @@ def discover_integration_setups(hass: HomeAssistant) -> list[dict[str, Any]]:
continue
# Empty keys (non-sensor domains only, tripwire-enforced) match
# the device's single entity of that domain — THE lawn_mower.
if sig.keys and not any(_entity_matches(entry, key) for key in sig.keys):
if sig.keys and not any(_matches_catalog_key(entry, key, catalog_keys) for key in sig.keys):
continue
group = matched.setdefault(device_id, {}).setdefault(
(integration, sig.task_name, sig.direction),
@@ -75,8 +75,12 @@ _DEFAULT_ABOVE_HOURS = 100
# Canonical units between services for lifetime counters (usage_delta mode):
# hours for operating-time counters, kilometres for odometers.
_DEFAULT_DELTA_UNITS = 500
# Percent floor for percent-remaining consumables (ink, toner, drum, brush %).
_DEFAULT_BELOW_PERCENT = 10
# Percent floor for percent-remaining consumables (ink, toner, drum, brush %)
# — the catalog default is "follow the household setting" (#146), so the
# field is None unless a signature pins its own floor. A sentinel value
# (the old 10) could not tell "pinned at 10" from "default" (bug review
# 2026-09-04).
_DEFAULT_BELOW_PERCENT: int | None = None
@dataclass(frozen=True)
@@ -87,7 +91,7 @@ class ConsumableSignature:
task_name: str # EN task name; localized through templates_i18n
direction: str # duration_left | percent_left | usage_above | event_present | usage_delta | runtime_hours
below_hours: int = _DEFAULT_BELOW_HOURS
below_percent: int = _DEFAULT_BELOW_PERCENT
below_percent: int | None = _DEFAULT_BELOW_PERCENT
above_hours: int = _DEFAULT_ABOVE_HOURS
delta_units: int = _DEFAULT_DELTA_UNITS
# runtime_hours signatures target a non-sensor STATE entity; empty keys
@@ -246,8 +250,8 @@ def _threshold_for(sig: ConsumableSignature, hass: HomeAssistant, entity_id: str
return float(sig.delta_units)
if sig.direction == "percent_left":
# #146: catalog-default floors follow the household setting; a
# signature that pins its own floor keeps it.
if sig.below_percent == _DEFAULT_BELOW_PERCENT:
# signature that pins its own floor (any value, 10 included) keeps it.
if sig.below_percent is None:
from ..global_options import get_consumable_threshold
return float(get_consumable_threshold(hass))
@@ -313,6 +313,8 @@ def compute_preview(
"confidence": confidence,
"events": [{"date": e.date.isoformat(), "status": e.status} for e in events],
"will_suppress": task_id not in state.exempt_task_ids,
# Absent = allowed, mirroring the task payload (#150).
"allow_skip": t.get("allow_skip") is not False,
}
)
# Stable ordering: object name then task name, matches #40 sort