189 files
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user