186 files
This commit is contained in:
@@ -188,6 +188,11 @@ class Battery:
|
||||
# and one in a thermostat share nothing but the cell; devices of the same
|
||||
# model do. Empty when the device is unknown.
|
||||
model_key: str = ""
|
||||
# #180: ``low`` is held by the recovery latch — the reading itself is no
|
||||
# longer at/below the floor, but it has not risen above
|
||||
# ``battery_recovered_percent`` (and no replacement was recorded) since
|
||||
# it went low. A level hovering around the floor stays ONE low episode.
|
||||
latched: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -372,6 +377,9 @@ def _row(
|
||||
"no_sensor": bat.no_sensor,
|
||||
"can_mark_replaced": bat.can_mark_replaced,
|
||||
"last_replaced": bat.last_replaced.isoformat() if bat.last_replaced else None,
|
||||
# #180: low only because the recovery latch holds it (the reading is
|
||||
# back above the floor but not yet above battery_recovered_percent).
|
||||
"latched": bat.latched,
|
||||
}
|
||||
|
||||
|
||||
@@ -472,6 +480,148 @@ def fleet_due_without_sensor(hass: HomeAssistant) -> bool:
|
||||
return _fleet_object(hass).get(BATTERY_FLEET_DUE_WITHOUT_SENSOR) is not False
|
||||
|
||||
|
||||
def get_battery_recovered_percent(hass: HomeAssistant) -> int:
|
||||
"""#180: the level a low battery must rise ABOVE to count as recovered
|
||||
(replaced). Out-of-range / junk values fall back to the default."""
|
||||
from ..const import BATTERY_RECOVERED_PERCENT_RANGE, CONF_BATTERY_RECOVERED_PERCENT, DEFAULT_BATTERY_RECOVERED_PERCENT
|
||||
from .global_options import get_global_options
|
||||
|
||||
raw = get_global_options(hass).get(CONF_BATTERY_RECOVERED_PERCENT, DEFAULT_BATTERY_RECOVERED_PERCENT)
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_BATTERY_RECOVERED_PERCENT
|
||||
lo, hi = BATTERY_RECOVERED_PERCENT_RANGE
|
||||
return value if lo <= value <= hi else DEFAULT_BATTERY_RECOVERED_PERCENT
|
||||
|
||||
|
||||
# ── #180: the low-recovery latch ─────────────────────────────────────────────
|
||||
#
|
||||
# A Hue dimmer's level oscillated around the low floor several times a day;
|
||||
# the low-count sensor flipped 0 ↔ 1 and the fleet task — auto-complete on
|
||||
# recovery — recorded a "completion" on every dip. Once a LEVEL-bearing
|
||||
# battery goes low it is latched: it stays counted low (needs_now, the
|
||||
# low-count sensor, the Needs-now list) until its level rises ABOVE
|
||||
# ``battery_recovered_percent``, or its Battery Notes ``battery_last_replaced``
|
||||
# date moves forward (a recorded replacement releases it at any level), or the
|
||||
# Replaced action / the record-replacement command releases it directly.
|
||||
#
|
||||
# Level-less rows (a low-only binary, a native binary battery) are NOT
|
||||
# latched: their binary saying "not low" is the all-clear, exactly as before.
|
||||
# Sensorless notes (D#162) leave ``low`` only when their forecast re-anchors.
|
||||
#
|
||||
# Persisted on the fleet task's Store state ({entity_id: {at, last_replaced}})
|
||||
# so a restart does not re-open the episode; without a fleet the latch lives
|
||||
# in memory (the low-count sensor exists before the fleet does).
|
||||
|
||||
LOW_LATCH_KEY = "battery_low_latch"
|
||||
_LATCH_MEMORY_KEY = "battery_fleet_low_latch_memory"
|
||||
|
||||
|
||||
def _replaced_since(last_replaced: str | None, entry: dict[str, Any]) -> bool:
|
||||
"""Whether the battery's current last-replaced date is NEWER than the one
|
||||
seen when it latched (a recorded replacement)."""
|
||||
if last_replaced is None:
|
||||
return False
|
||||
seen = entry.get("last_replaced")
|
||||
return not isinstance(seen, str) or last_replaced > seen
|
||||
|
||||
|
||||
def apply_low_latch(
|
||||
batteries: list[Battery],
|
||||
latch: dict[str, Any],
|
||||
*,
|
||||
recovered: float,
|
||||
now_iso: str,
|
||||
eligible: set[str] | None = None,
|
||||
) -> bool:
|
||||
"""Pure latch step: mutate ``bat.low``/``bat.latched`` and the ``latch``
|
||||
map in place. Returns True when the map changed (the caller persists).
|
||||
|
||||
``eligible`` = entity ids whose ``low`` is level-driven (default: every
|
||||
battery with a level, never a sensorless note). Per eligible battery:
|
||||
|
||||
* reading low → latch it (or re-latch when a replacement was recorded
|
||||
and the fresh reading is STILL low — a new episode);
|
||||
* latched + unavailable → stays low (no reading is no proof of recovery);
|
||||
* latched + reading not low → released when the level is above
|
||||
``recovered`` or a replacement was recorded, else held low.
|
||||
|
||||
Entries for batteries no longer in the fleet are dropped.
|
||||
"""
|
||||
changed = False
|
||||
if eligible is None:
|
||||
eligible = {b.entity_id for b in batteries if b.level is not None and not b.no_sensor}
|
||||
for bat in batteries:
|
||||
if bat.entity_id not in eligible:
|
||||
continue
|
||||
last = bat.last_replaced.isoformat() if bat.last_replaced else None
|
||||
raw_entry = latch.get(bat.entity_id)
|
||||
entry = raw_entry if isinstance(raw_entry, dict) else None
|
||||
if bat.low:
|
||||
if entry is None or _replaced_since(last, entry):
|
||||
latch[bat.entity_id] = {"at": now_iso, "last_replaced": last}
|
||||
changed = True
|
||||
continue
|
||||
if entry is None:
|
||||
continue
|
||||
if bat.available and (_replaced_since(last, entry) or (bat.level is not None and bat.level > recovered)):
|
||||
del latch[bat.entity_id]
|
||||
changed = True
|
||||
continue
|
||||
bat.low = True
|
||||
bat.latched = True
|
||||
known = {b.entity_id for b in batteries}
|
||||
for eid in [e for e in latch if e not in known]:
|
||||
del latch[eid]
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def _latch_backend(hass: HomeAssistant) -> tuple[dict[str, Any], Callable[[], None]]:
|
||||
"""The latch map + a save callback: the fleet task's Store state when a
|
||||
fleet exists, else an in-memory map (nothing to persist to yet)."""
|
||||
from ..const import DOMAIN
|
||||
from .battery_lifetime import fleet_store_and_task
|
||||
|
||||
found = fleet_store_and_task(hass)
|
||||
if found is not None:
|
||||
store, task_id = found
|
||||
raw = store.get_task_state(task_id).get(LOW_LATCH_KEY)
|
||||
latch: dict[str, Any] = dict(raw) if isinstance(raw, dict) else {}
|
||||
|
||||
def _save() -> None:
|
||||
store.update_task_state(task_id, **{LOW_LATCH_KEY: latch})
|
||||
store.async_delay_save()
|
||||
|
||||
return latch, _save
|
||||
memory: dict[str, Any] = hass.data.setdefault(DOMAIN, {}).setdefault(_LATCH_MEMORY_KEY, {})
|
||||
return memory, lambda: None
|
||||
|
||||
|
||||
def _apply_low_latch(hass: HomeAssistant, batteries: list[Battery], eligible: set[str]) -> None:
|
||||
latch, save = _latch_backend(hass)
|
||||
if apply_low_latch(
|
||||
batteries, latch, recovered=float(get_battery_recovered_percent(hass)), now_iso=dt_util.utcnow().isoformat(), eligible=eligible
|
||||
):
|
||||
save()
|
||||
|
||||
|
||||
def release_low_latch(hass: HomeAssistant, entity_ids: list[str]) -> int:
|
||||
"""Drop the latch for batteries the user just marked replaced (the
|
||||
Replaced action / a recorded replacement) — the count must not wait for
|
||||
Battery Notes to echo the new date. Returns how many were released."""
|
||||
latch, save = _latch_backend(hass)
|
||||
released = 0
|
||||
for eid in entity_ids:
|
||||
if eid in latch:
|
||||
del latch[eid]
|
||||
released += 1
|
||||
if released:
|
||||
save()
|
||||
return released
|
||||
|
||||
|
||||
def device_model_key(hass: HomeAssistant, device_id: str | None) -> str:
|
||||
""""manufacturer|model" (lowercased) for a device registry id, "" when
|
||||
unknown — the learning pool key (see battery_lifetime)."""
|
||||
@@ -609,6 +759,9 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
|
||||
out: list[Battery] = []
|
||||
covered_sources: set[str] = set()
|
||||
covered_devices: set[str] = set()
|
||||
# #180: rows whose ``low`` is decided by a LEVEL — the recovery latch
|
||||
# applies to these only (a binary's "off" is its own all-clear).
|
||||
latch_eligible: set[str] = set()
|
||||
|
||||
# ── Pass 1: Battery Notes battery_plus ──────────────────────────────────
|
||||
# Percentage SENSORS first, then LOW-ONLY BINARIES (#121): a source with
|
||||
@@ -670,6 +823,7 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
|
||||
# native pass. A HIGHER Battery Notes threshold (e.g. 30 %)
|
||||
# still wins through battery_low.
|
||||
low = bool(attrs.get("battery_low")) or (level is not None and level <= floor)
|
||||
latch_eligible.add(state.entity_id)
|
||||
last_replaced = _parse_last_replaced(attrs.get("battery_last_replaced"))
|
||||
# B1 (roadmap 2026-07-22 audit): a forecast-only note — no level
|
||||
# sensor, so the state reads unknown forever — must SURVIVE when it
|
||||
@@ -794,6 +948,7 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
|
||||
low = low_available and str(low_state).lower() in ("on", "true", "1")
|
||||
else:
|
||||
low = level is not None and level <= floor
|
||||
latch_eligible.add(rec["eid"])
|
||||
if available:
|
||||
# Remember the last real reading — the retention path below needs
|
||||
# it once the entity goes unavailable.
|
||||
@@ -914,6 +1069,10 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
|
||||
pred = _predicted_date(bat, lifetime_for(bat).months)
|
||||
bat.low = pred is not None and pred < today
|
||||
out.append(bat)
|
||||
# #180: hold level-driven rows low until they clearly recover (or a
|
||||
# replacement is recorded) — AFTER every pass, so the sensor, the panel
|
||||
# overview and the Replaced action all see the same ``low``.
|
||||
_apply_low_latch(hass, out, latch_eligible)
|
||||
for bat in out:
|
||||
if bat.source == "battery_notes":
|
||||
bat.can_mark_replaced = has_replaced_button(hass, bat.entity_id)
|
||||
@@ -1140,6 +1299,7 @@ def _detect_unrecorded_jump(
|
||||
last_replaced: date | None,
|
||||
*,
|
||||
rechargeable: bool = False,
|
||||
recovered: float | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""An upward level step that looks like a swap nobody recorded.
|
||||
|
||||
@@ -1148,6 +1308,10 @@ def _detect_unrecorded_jump(
|
||||
silently anchoring the type-lifetime forecast to the DEAD battery. The
|
||||
step is unmistakable in the recorder, so surface it and offer to record
|
||||
it. Rechargeables are exempt: their packs jump on every routine charge.
|
||||
|
||||
``recovered`` (#181 noise, the #180 ``battery_recovered_percent``): a rise
|
||||
that does not land ABOVE it is a bounce around the low floor, not a
|
||||
swap — the chip kept offering "newer dates" for those.
|
||||
"""
|
||||
from itertools import pairwise
|
||||
|
||||
@@ -1156,6 +1320,8 @@ def _detect_unrecorded_jump(
|
||||
for (_, v_prev), (ts, v) in pairwise(points):
|
||||
if v - v_prev < _JUMP_MIN_RISE:
|
||||
continue
|
||||
if recovered is not None and v <= recovered:
|
||||
continue # did not cross the recovery threshold: a bounce, not a swap
|
||||
jump_date = dt_util.utc_from_timestamp(ts).date()
|
||||
if last_replaced is not None and abs((jump_date - last_replaced).days) <= _JUMP_RECORDED_SLACK_DAYS:
|
||||
continue # already recorded
|
||||
@@ -1191,6 +1357,7 @@ async def async_level_history(hass: HomeAssistant, batteries: list[Battery]) ->
|
||||
now = dt_util.utcnow()
|
||||
predictor = SensorPredictor(hass)
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
recovered = float(get_battery_recovered_percent(hass))
|
||||
|
||||
for bat in batteries:
|
||||
if bat.no_sensor or (bat.level is None and not bat.low):
|
||||
@@ -1212,7 +1379,9 @@ async def async_level_history(hass: HomeAssistant, batteries: list[Battery]) ->
|
||||
"points": [[round(ts), round(v, 1)] for ts, v in points],
|
||||
"threshold": bat.low_threshold,
|
||||
}
|
||||
jump = _detect_unrecorded_jump(points, bat.last_replaced, rechargeable=is_rechargeable_type(bat.battery_type))
|
||||
jump = _detect_unrecorded_jump(
|
||||
points, bat.last_replaced, rechargeable=is_rechargeable_type(bat.battery_type), recovered=recovered
|
||||
)
|
||||
if jump is not None:
|
||||
# The Battery Notes service that records a replacement takes
|
||||
# the DEVICE — resolve it here so the panel's one-click fix
|
||||
@@ -1251,10 +1420,12 @@ def discover_battery_types(hass: HomeAssistant) -> OrderedDict[str, int]:
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_HORIZON_DAYS",
|
||||
"LOW_LATCH_KEY",
|
||||
"NATIVE_LOW_PERCENT",
|
||||
"TYPICAL_LIFETIME_MONTHS",
|
||||
"Battery",
|
||||
"BatteryOverview",
|
||||
"apply_low_latch",
|
||||
"async_compute_overview",
|
||||
"async_level_history",
|
||||
"async_trend_predictions",
|
||||
@@ -1267,10 +1438,12 @@ __all__ = [
|
||||
"fleet_excluded_entities",
|
||||
"fleet_included_entities",
|
||||
"fleet_track_self_charging",
|
||||
"get_battery_recovered_percent",
|
||||
"has_batteries",
|
||||
"has_battery_notes",
|
||||
"is_rechargeable_type",
|
||||
"lifetime_months",
|
||||
"note_sibling_entity",
|
||||
"read_batteries",
|
||||
"release_low_latch",
|
||||
]
|
||||
|
||||
@@ -34,7 +34,14 @@ from ..const import (
|
||||
DOMAIN,
|
||||
GLOBAL_UNIQUE_ID,
|
||||
)
|
||||
from .battery_fleet import canonical_type, discover_battery_types, lifetime_months, note_sibling_entity, read_batteries
|
||||
from .battery_fleet import (
|
||||
canonical_type,
|
||||
discover_battery_types,
|
||||
lifetime_months,
|
||||
note_sibling_entity,
|
||||
read_batteries,
|
||||
release_low_latch,
|
||||
)
|
||||
from .global_options import get_default_warning_days
|
||||
from .trigger_fallback import threshold_limits_overlap
|
||||
|
||||
@@ -362,6 +369,7 @@ async def async_mark_replaced(hass: HomeAssistant, entity_ids: list[str] | None
|
||||
targets = entity_ids if entity_ids is not None else [e for e, b in by_eid.items() if b.low]
|
||||
|
||||
pressed = 0
|
||||
pressed_ids: list[str] = []
|
||||
by_type: dict[str, int] = {}
|
||||
for eid in targets:
|
||||
bat = by_eid.get(eid)
|
||||
@@ -378,22 +386,101 @@ async def async_mark_replaced(hass: HomeAssistant, entity_ids: list[str] | None
|
||||
continue
|
||||
await hass.services.async_call("button", "press", {"entity_id": button}, blocking=False)
|
||||
pressed += 1
|
||||
pressed_ids.append(eid)
|
||||
t = canonical_type(bat.battery_type)
|
||||
by_type[t] = by_type.get(t, 0) + bat.quantity
|
||||
|
||||
# #180: a marked battery leaves the low latch now — the count must not
|
||||
# wait for Battery Notes to echo the new date (nor for the level).
|
||||
release_low_latch(hass, pressed_ids)
|
||||
consumed = await async_consume_type_parts(hass, by_type)
|
||||
return {"marked": pressed, "pressed": pressed, "consumed": consumed}
|
||||
|
||||
|
||||
async def async_consume_type_parts(hass: HomeAssistant, by_type: dict[str, int]) -> dict[str, int]:
|
||||
"""Take ``{canonical type: cells}`` out of the fleet's type-part stock.
|
||||
Returns ``{part_id: qty}`` for the parts that exist (an untyped or
|
||||
unstocked type consumes nothing). The ONE consumption path behind the
|
||||
Replaced action and the record-replacement command (#181)."""
|
||||
consumed: dict[str, int] = {}
|
||||
fleet = find_fleet_entry(hass)
|
||||
if fleet is not None and by_type:
|
||||
from ..parts_runtime import async_change_part_stock
|
||||
if fleet is None or not by_type:
|
||||
return consumed
|
||||
from ..parts_runtime import async_change_part_stock
|
||||
|
||||
parts = fleet.data.get(CONF_PARTS) or {}
|
||||
for btype, qty in by_type.items():
|
||||
pid = f"batt_{btype.lower()}"
|
||||
if pid in parts:
|
||||
await async_change_part_stock(hass, fleet, pid, delta=-qty)
|
||||
consumed[pid] = qty
|
||||
parts = fleet.data.get(CONF_PARTS) or {}
|
||||
for btype, qty in by_type.items():
|
||||
pid = f"{FLEET_PART_PREFIX}{btype.lower()}"
|
||||
if pid in parts:
|
||||
await async_change_part_stock(hass, fleet, pid, delta=-qty)
|
||||
consumed[pid] = qty
|
||||
return consumed
|
||||
|
||||
return {"marked": pressed, "pressed": pressed, "consumed": consumed}
|
||||
|
||||
# Store key on the fleet task's dynamic state: {entity_id: iso date} of the
|
||||
# replacements recorded through the record-replacement command — the
|
||||
# idempotency memory (Battery Notes echoes the date only after its service
|
||||
# ran; a repeat click for the same day must not consume twice).
|
||||
RECORDED_REPLACEMENTS_KEY = "battery_recorded_replacements"
|
||||
_RECORDED_CAP = 500
|
||||
|
||||
|
||||
async def async_record_replacement(hass: HomeAssistant, entity_id: str, replaced_at: str) -> dict[str, Any]:
|
||||
"""Record a replacement the level history revealed (#181, the roster's
|
||||
calendar-sync chip): write the date through Battery Notes'
|
||||
``set_battery_replaced`` — the same call the panel used to make itself
|
||||
— AND consume the battery type's cells from the type-part stock through
|
||||
:func:`async_consume_type_parts`, exactly like the Replaced action.
|
||||
|
||||
Idempotent per calendar day: the same date for the same battery is
|
||||
recorded again (harmless) but never consumed twice. Raises
|
||||
:class:`HomeAssistantError` with a WS-ready code when the battery is
|
||||
unknown, has no device, or Battery Notes is not available.
|
||||
"""
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .battery_lifetime import fleet_store_and_task
|
||||
|
||||
parsed = dt_util.parse_datetime(replaced_at)
|
||||
if parsed is None:
|
||||
raise HomeAssistantError("invalid_date")
|
||||
bat = next((b for b in read_batteries(hass) if b.entity_id == entity_id), None)
|
||||
if bat is None:
|
||||
raise HomeAssistantError("not_found")
|
||||
reg = er.async_get(hass).async_get(entity_id)
|
||||
if reg is None or not reg.device_id:
|
||||
raise HomeAssistantError("invalid_device")
|
||||
if not hass.services.has_service("battery_notes", "set_battery_replaced"):
|
||||
raise HomeAssistantError("not_available")
|
||||
|
||||
day = (dt_util.as_local(parsed) if parsed.tzinfo is not None else parsed).date().isoformat()
|
||||
found = fleet_store_and_task(hass)
|
||||
recorded: dict[str, Any] = {}
|
||||
if found is not None:
|
||||
raw = found[0].get_task_state(found[1]).get(RECORDED_REPLACEMENTS_KEY)
|
||||
recorded = dict(raw) if isinstance(raw, dict) else {}
|
||||
already = recorded.get(entity_id) == day or (bat.last_replaced is not None and bat.last_replaced.isoformat() == day)
|
||||
|
||||
await hass.services.async_call(
|
||||
"battery_notes",
|
||||
"set_battery_replaced",
|
||||
{"device_id": reg.device_id, "datetime_replaced": parsed.isoformat()},
|
||||
blocking=False,
|
||||
)
|
||||
release_low_latch(hass, [entity_id])
|
||||
|
||||
consumed: dict[str, int] = {}
|
||||
if not already:
|
||||
consumed = await async_consume_type_parts(hass, {canonical_type(bat.battery_type): bat.quantity})
|
||||
if found is not None:
|
||||
store, task_id = found
|
||||
recorded[entity_id] = day
|
||||
if len(recorded) > _RECORDED_CAP:
|
||||
for old in list(recorded)[: len(recorded) - _RECORDED_CAP]:
|
||||
recorded.pop(old, None)
|
||||
store.update_task_state(task_id, **{RECORDED_REPLACEMENTS_KEY: recorded})
|
||||
store.async_delay_save()
|
||||
return {"recorded": True, "already_recorded": already, "consumed": consumed}
|
||||
|
||||
|
||||
def _mutate_fleet_object(hass: HomeAssistant, mutate: Callable[[dict[str, Any]], None]) -> bool:
|
||||
|
||||
@@ -114,6 +114,7 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"reason_voice": 'per Sprache erledigt',
|
||||
"reason_notification_action": 'aus der Benachrichtigung erledigt',
|
||||
"reason_shopping_list": 'über die Einkaufsliste nachgefüllt',
|
||||
"reason_todo_mirror": 'in einer gespiegelten To-do-Liste erledigt',
|
||||
"reason_service": 'von einer Automation erledigt',
|
||||
"reason_auto_recovery": 'automatisch erledigt, der Sensor hat sich erholt',
|
||||
"reason_unknown": 'erledigt',
|
||||
@@ -153,6 +154,7 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"reason_voice": 'voltooid via spraak',
|
||||
"reason_notification_action": 'voltooid vanuit de notificatie',
|
||||
"reason_shopping_list": 'aangevuld via de boodschappenlijst',
|
||||
"reason_todo_mirror": 'afgevinkt in een gespiegelde takenlijst',
|
||||
"reason_service": 'voltooid door een automatisering',
|
||||
"reason_auto_recovery": 'automatisch voltooid, de sensor is hersteld',
|
||||
"reason_unknown": 'voltooid',
|
||||
@@ -192,6 +194,7 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"reason_voice": 'terminée à la voix',
|
||||
"reason_notification_action": 'terminée depuis la notification',
|
||||
"reason_shopping_list": 'réapprovisionnée via la liste de courses',
|
||||
"reason_todo_mirror": 'cochée dans une liste de tâches miroir',
|
||||
"reason_service": 'terminée par une automatisation',
|
||||
"reason_auto_recovery": "terminée automatiquement, le capteur s'est rétabli",
|
||||
"reason_unknown": 'terminée',
|
||||
@@ -231,6 +234,7 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"reason_voice": 'completata a voce',
|
||||
"reason_notification_action": 'completata dalla notifica',
|
||||
"reason_shopping_list": 'rifornita dalla lista della spesa',
|
||||
"reason_todo_mirror": 'spuntata in una lista di cose da fare specchiata',
|
||||
"reason_service": "completata da un'automazione",
|
||||
"reason_auto_recovery": 'completata automaticamente, il sensore si è ripristinato',
|
||||
"reason_unknown": 'completata',
|
||||
@@ -270,6 +274,7 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"reason_voice": 'completada por voz',
|
||||
"reason_notification_action": 'completada desde la notificación',
|
||||
"reason_shopping_list": 'repuesta desde la lista de la compra',
|
||||
"reason_todo_mirror": 'marcada en una lista de tareas reflejada',
|
||||
"reason_service": 'completada por una automatización',
|
||||
"reason_auto_recovery": 'completada automáticamente, el sensor se recuperó',
|
||||
"reason_unknown": 'completada',
|
||||
@@ -309,6 +314,7 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"reason_voice": 'completed by voice',
|
||||
"reason_notification_action": 'completed from the notification',
|
||||
"reason_shopping_list": 'restocked from the shopping list',
|
||||
"reason_todo_mirror": 'checked off in a mirrored to-do list',
|
||||
"reason_service": 'completed by an automation',
|
||||
"reason_auto_recovery": 'auto-completed, the sensor recovered',
|
||||
"reason_unknown": 'completed',
|
||||
@@ -348,6 +354,7 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"reason_voice": 'fuldført med stemmen',
|
||||
"reason_notification_action": 'fuldført fra notifikationen',
|
||||
"reason_shopping_list": 'genopfyldt fra indkøbslisten',
|
||||
"reason_todo_mirror": 'afkrydset på en spejlet to-do-liste',
|
||||
"reason_service": 'fuldført af en automatisering',
|
||||
"reason_auto_recovery": 'fuldført automatisk, sensoren kom sig',
|
||||
"reason_unknown": 'fuldført',
|
||||
@@ -387,6 +394,7 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"reason_voice": 'suoritettu puheella',
|
||||
"reason_notification_action": 'suoritettu ilmoituksesta',
|
||||
"reason_shopping_list": 'täydennetty ostoslistalta',
|
||||
"reason_todo_mirror": 'kuitattu peilatussa tehtävälistassa',
|
||||
"reason_service": 'suoritettu automaatiolla',
|
||||
"reason_auto_recovery": 'suoritettu automaattisesti, anturi palautui',
|
||||
"reason_unknown": 'suoritettu',
|
||||
@@ -426,6 +434,7 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"reason_voice": 'fullført med stemme',
|
||||
"reason_notification_action": 'fullført fra varselet',
|
||||
"reason_shopping_list": 'etterfylt fra handlelisten',
|
||||
"reason_todo_mirror": 'krysset av i en speilet gjøremålsliste',
|
||||
"reason_service": 'fullført av en automasjon',
|
||||
"reason_auto_recovery": 'fullført automatisk, sensoren kom seg',
|
||||
"reason_unknown": 'fullført',
|
||||
@@ -465,6 +474,7 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"reason_voice": '音声で完了',
|
||||
"reason_notification_action": '通知から完了',
|
||||
"reason_shopping_list": '買い物リストから補充',
|
||||
"reason_todo_mirror": 'ミラーされたToDoリストで完了',
|
||||
"reason_service": 'オートメーションで完了',
|
||||
"reason_auto_recovery": 'センサー回復により自動完了',
|
||||
"reason_unknown": '完了',
|
||||
@@ -504,6 +514,7 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"reason_voice": 'आवाज़ से पूरा किया',
|
||||
"reason_notification_action": 'सूचना से पूरा किया',
|
||||
"reason_shopping_list": 'खरीदारी सूची से पुनः भरा',
|
||||
"reason_todo_mirror": 'मिरर की गई टू-डू सूची में पूरा किया गया',
|
||||
"reason_service": 'ऑटोमेशन द्वारा पूरा किया',
|
||||
"reason_auto_recovery": 'स्वतः पूरा, सेंसर ठीक हो गया',
|
||||
"reason_unknown": 'पूरा किया',
|
||||
@@ -543,6 +554,7 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"reason_voice": '通过语音完成',
|
||||
"reason_notification_action": '从通知中完成',
|
||||
"reason_shopping_list": '从购物清单补货',
|
||||
"reason_todo_mirror": '在镜像的待办清单中勾选完成',
|
||||
"reason_service": '由自动化完成',
|
||||
"reason_auto_recovery": '传感器恢复,自动完成',
|
||||
"reason_unknown": '已完成',
|
||||
@@ -582,6 +594,7 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"reason_voice": 'выполнено голосом',
|
||||
"reason_notification_action": 'выполнено из уведомления',
|
||||
"reason_shopping_list": 'пополнено из списка покупок',
|
||||
"reason_todo_mirror": 'отмечено в зеркальном списке дел',
|
||||
"reason_service": 'выполнено автоматизацией',
|
||||
"reason_auto_recovery": 'выполнено автоматически: датчик восстановился',
|
||||
"reason_unknown": 'выполнено',
|
||||
@@ -621,6 +634,7 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"reason_voice": 'виконано голосом',
|
||||
"reason_notification_action": 'виконано зі сповіщення',
|
||||
"reason_shopping_list": 'поповнено зі списку покупок',
|
||||
"reason_todo_mirror": 'відмічено у дзеркальному списку справ',
|
||||
"reason_service": 'виконано автоматизацією',
|
||||
"reason_auto_recovery": 'виконано автоматично: датчик відновився',
|
||||
"reason_unknown": 'виконано',
|
||||
@@ -660,6 +674,7 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"reason_voice": 'concluída por voz',
|
||||
"reason_notification_action": 'concluída a partir da notificação',
|
||||
"reason_shopping_list": 'reposta a partir da lista de compras',
|
||||
"reason_todo_mirror": 'concluída numa lista de tarefas espelhada',
|
||||
"reason_service": 'concluída por uma automação',
|
||||
"reason_auto_recovery": 'concluída automaticamente, o sensor recuperou',
|
||||
"reason_unknown": 'concluída',
|
||||
@@ -701,6 +716,7 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"reason_voice": 'ukończone głosowo',
|
||||
"reason_notification_action": 'ukończone z powiadomienia',
|
||||
"reason_shopping_list": 'uzupełnione z listy zakupów',
|
||||
"reason_todo_mirror": 'odhaczone na lustrzanej liście zadań',
|
||||
"reason_service": 'ukończone przez automatyzację',
|
||||
"reason_auto_recovery": 'ukończone automatycznie, czujnik wrócił do normy',
|
||||
"reason_unknown": 'ukończone',
|
||||
@@ -742,6 +758,7 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"reason_voice": 'dokončeno hlasem',
|
||||
"reason_notification_action": 'dokončeno z oznámení',
|
||||
"reason_shopping_list": 'doplněno z nákupního seznamu',
|
||||
"reason_todo_mirror": 'odškrtnuto v zrcadleném seznamu úkolů',
|
||||
"reason_service": 'dokončeno automatizací',
|
||||
"reason_auto_recovery": 'dokončeno automaticky, senzor se zotavil',
|
||||
"reason_unknown": 'dokončeno',
|
||||
@@ -782,6 +799,7 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"reason_voice": 'slutförd med rösten',
|
||||
"reason_notification_action": 'slutförd från aviseringen',
|
||||
"reason_shopping_list": 'påfylld via inköpslistan',
|
||||
"reason_todo_mirror": 'avbockad i en speglad att-göra-lista',
|
||||
"reason_service": 'slutförd av en automation',
|
||||
"reason_auto_recovery": 'slutförd automatiskt, sensorn återhämtade sig',
|
||||
"reason_unknown": 'slutförd',
|
||||
@@ -821,6 +839,7 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"reason_voice": 'concluída por voz',
|
||||
"reason_notification_action": 'concluída pela notificação',
|
||||
"reason_shopping_list": 'reposta pela lista de compras',
|
||||
"reason_todo_mirror": 'concluída em uma lista de tarefas espelhada',
|
||||
"reason_service": 'concluída por uma automação',
|
||||
"reason_auto_recovery": 'concluída automaticamente, o sensor se recuperou',
|
||||
"reason_unknown": 'concluída',
|
||||
@@ -860,6 +879,7 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"reason_voice": 'hanggal elvégezve',
|
||||
"reason_notification_action": 'az értesítésből elvégezve',
|
||||
"reason_shopping_list": 'a bevásárlólistáról feltöltve',
|
||||
"reason_todo_mirror": 'kipipálva egy tükrözött teendőlistán',
|
||||
"reason_service": 'automatizálás végezte el',
|
||||
"reason_auto_recovery": 'automatikusan elvégezve, az érzékelő helyreállt',
|
||||
"reason_unknown": 'elvégezve',
|
||||
@@ -899,6 +919,7 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"reason_voice": '음성으로 완료',
|
||||
"reason_notification_action": '알림에서 완료',
|
||||
"reason_shopping_list": '쇼핑 목록에서 보충',
|
||||
"reason_todo_mirror": '미러링된 할 일 목록에서 완료',
|
||||
"reason_service": '자동화가 완료',
|
||||
"reason_auto_recovery": '센서 복구로 자동 완료',
|
||||
"reason_unknown": '완료',
|
||||
@@ -938,6 +959,7 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"reason_voice": 'sesle tamamlandı',
|
||||
"reason_notification_action": 'bildirimden tamamlandı',
|
||||
"reason_shopping_list": 'alışveriş listesinden yenilendi',
|
||||
"reason_todo_mirror": 'yansıtılmış bir yapılacaklar listesinde tamamlandı',
|
||||
"reason_service": 'bir otomasyon tarafından tamamlandı',
|
||||
"reason_auto_recovery": 'otomatik tamamlandı, sensör toparlandı',
|
||||
"reason_unknown": 'tamamlandı',
|
||||
|
||||
@@ -84,14 +84,42 @@ _BUY_NAME_TEMPLATES = {
|
||||
"tr": "{name} satın al",
|
||||
}
|
||||
|
||||
# Default shopping-search templates by UI language (the "Amazon as fallback"
|
||||
# decision); overridable via the global ``part_search_url_template`` setting.
|
||||
# Default shopping-search templates (the "Amazon as fallback" decision);
|
||||
# overridable via the global ``part_search_url_template`` setting. D#182: the
|
||||
# HA COUNTRY decides first (an English UI in Germany shops at amazon.de); the
|
||||
# UI language is the fallback while the country is unset.
|
||||
_COUNTRY_SEARCH_TEMPLATES = {
|
||||
"DE": "https://www.amazon.de/s?k={q}",
|
||||
"AT": "https://www.amazon.de/s?k={q}",
|
||||
"CH": "https://www.amazon.de/s?k={q}",
|
||||
"FR": "https://www.amazon.fr/s?k={q}",
|
||||
"IT": "https://www.amazon.it/s?k={q}",
|
||||
"ES": "https://www.amazon.es/s?k={q}",
|
||||
"NL": "https://www.amazon.nl/s?k={q}",
|
||||
"BE": "https://www.amazon.nl/s?k={q}",
|
||||
"PL": "https://www.amazon.pl/s?k={q}",
|
||||
"SE": "https://www.amazon.se/s?k={q}",
|
||||
"TR": "https://www.amazon.com.tr/s?k={q}",
|
||||
"BR": "https://www.amazon.com.br/s?k={q}",
|
||||
"GB": "https://www.amazon.co.uk/s?k={q}",
|
||||
"UK": "https://www.amazon.co.uk/s?k={q}",
|
||||
"IE": "https://www.amazon.co.uk/s?k={q}",
|
||||
"CA": "https://www.amazon.ca/s?k={q}",
|
||||
"AU": "https://www.amazon.com.au/s?k={q}",
|
||||
"JP": "https://www.amazon.co.jp/s?k={q}",
|
||||
"IN": "https://www.amazon.in/s?k={q}",
|
||||
"MX": "https://www.amazon.com.mx/s?k={q}",
|
||||
"US": "https://www.amazon.com/s?k={q}",
|
||||
}
|
||||
_DEFAULT_SEARCH_TEMPLATES = {
|
||||
"de": "https://www.amazon.de/s?k={q}",
|
||||
"fr": "https://www.amazon.fr/s?k={q}",
|
||||
"it": "https://www.amazon.it/s?k={q}",
|
||||
"es": "https://www.amazon.es/s?k={q}",
|
||||
"nl": "https://www.amazon.nl/s?k={q}",
|
||||
"pl": "https://www.amazon.pl/s?k={q}",
|
||||
"sv": "https://www.amazon.se/s?k={q}",
|
||||
"ja": "https://www.amazon.co.jp/s?k={q}",
|
||||
"pt-br": "https://www.amazon.com.br/s?k={q}",
|
||||
"tr": "https://www.amazon.com.tr/s?k={q}",
|
||||
}
|
||||
@@ -315,12 +343,25 @@ def stock_transition(part: Mapping[str, Any], old: float | None, new: float | No
|
||||
# ── Shopping-search URL ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def default_search_template(lang: str) -> str:
|
||||
def default_search_template(lang: str, country: str | None = None) -> str:
|
||||
"""The built-in shopping-search template: the HA country's Amazon store
|
||||
when the country is set and known (D#182), else the UI language's, else
|
||||
amazon.com."""
|
||||
from .i18n import normalize_language_code
|
||||
|
||||
code = str(country or "").strip().upper()
|
||||
if code in _COUNTRY_SEARCH_TEMPLATES:
|
||||
return _COUNTRY_SEARCH_TEMPLATES[code]
|
||||
return _DEFAULT_SEARCH_TEMPLATES.get(normalize_language_code(lang), _FALLBACK_SEARCH_TEMPLATE)
|
||||
|
||||
|
||||
def valid_search_template(template: Any) -> bool:
|
||||
"""Whether a user-supplied search template is usable: an http(s) URL
|
||||
carrying the ``{q}`` placeholder (the sanitiser's rule; "" = automatic)."""
|
||||
s = str(template or "").strip()
|
||||
return bool(s) and len(s) <= MAX_PART_URL and bool(re.match(r"^https?://", s)) and "{q}" in s
|
||||
|
||||
|
||||
def search_query(part: Mapping[str, Any]) -> str:
|
||||
"""Query precedence: GTIN (most precise) → "{vendor} {mpn}" → name."""
|
||||
if part.get("gtin"):
|
||||
@@ -332,11 +373,12 @@ def search_query(part: Mapping[str, Any]) -> str:
|
||||
return str(part.get("name") or "")
|
||||
|
||||
|
||||
def resolve_shopping_url(part: Mapping[str, Any], template: str | None, lang: str) -> str:
|
||||
"""The link to buy this part: ``product_url`` wins; else the search template."""
|
||||
def resolve_shopping_url(part: Mapping[str, Any], template: str | None, lang: str, country: str | None = None) -> str:
|
||||
"""The link to buy this part: ``product_url`` wins; else the search
|
||||
template — the explicit one, or the default by country then language."""
|
||||
if part.get("product_url"):
|
||||
return str(part["product_url"])
|
||||
tpl = (template or "").strip() or default_search_template(lang)
|
||||
tpl = (template or "").strip() or default_search_template(lang, country)
|
||||
if "{q}" not in tpl:
|
||||
tpl = tpl.rstrip("/") + "?q={q}"
|
||||
return tpl.replace("{q}", quote_plus(search_query(part)))
|
||||
@@ -391,6 +433,7 @@ def build_buy_task(
|
||||
search_template: str | None,
|
||||
today: date,
|
||||
decimals: int = DEFAULT_CURRENCY_DECIMALS,
|
||||
country: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""The one-off shopping reminder for a low part (due today, actionable now)."""
|
||||
return {
|
||||
@@ -405,7 +448,7 @@ def build_buy_task(
|
||||
"labels": [BUY_TASK_LABEL],
|
||||
"custom_icon": BUY_TASK_ICON,
|
||||
"notes": buy_task_notes(part, stock, decimals),
|
||||
"documentation_url": resolve_shopping_url(part, search_template, lang),
|
||||
"documentation_url": resolve_shopping_url(part, search_template, lang, country),
|
||||
PART_REF_FIELD: {"part_id": str(part["id"])},
|
||||
}
|
||||
|
||||
@@ -421,6 +464,7 @@ def reconcile_buy_tasks(
|
||||
today: date,
|
||||
decimals: int = DEFAULT_CURRENCY_DECIMALS,
|
||||
is_task_done: Any,
|
||||
country: str | None = None,
|
||||
) -> tuple[dict[str, dict[str, Any]], list[str], list[str], bool]:
|
||||
"""Compute the task map with auto "buy" reminders synced to low parts.
|
||||
|
||||
@@ -482,6 +526,7 @@ def reconcile_buy_tasks(
|
||||
search_template=search_template,
|
||||
today=today,
|
||||
decimals=decimals,
|
||||
country=country,
|
||||
)
|
||||
result[task["id"]] = task
|
||||
created.append(task["id"])
|
||||
|
||||
@@ -206,6 +206,15 @@ def cap_task_fields(task_data: dict[str, Any]) -> dict[str, Any]:
|
||||
if lb is not None:
|
||||
task_data["labels"] = sanitize_labels(lb)
|
||||
|
||||
# D#183: mirror targets — todo.* ids only, deduped, capped; an empty
|
||||
# result drops the key (absence = mirroring off).
|
||||
if task_data.get("mirror_todo_entities") is not None:
|
||||
mirrors = sanitize_mirror_todo_entities(task_data["mirror_todo_entities"])
|
||||
if mirrors:
|
||||
task_data["mirror_todo_entities"] = mirrors
|
||||
else:
|
||||
task_data.pop("mirror_todo_entities", None)
|
||||
|
||||
if task_data.get("assignee_pool") is not None:
|
||||
task_data["assignee_pool"] = sanitize_assignee_pool(task_data["assignee_pool"])
|
||||
|
||||
@@ -268,6 +277,28 @@ def sanitize_labels(value: object) -> list[str]:
|
||||
return out[:MAX_LABELS]
|
||||
|
||||
|
||||
def sanitize_mirror_todo_entities(value: object) -> list[str]:
|
||||
"""Clean a mirror-target list (D#183): ``todo.*`` entity ids only,
|
||||
trimmed, deduped, at most ``MAX_MIRROR_TODO_LISTS``."""
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
import re
|
||||
|
||||
from ..const import MAX_MIRROR_TODO_LISTS, MIRROR_TODO_ENTITY_PATTERN
|
||||
|
||||
pattern = re.compile(MIRROR_TODO_ENTITY_PATTERN)
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for item in value:
|
||||
if not isinstance(item, str):
|
||||
continue
|
||||
v = item.strip()
|
||||
if v and v not in seen and pattern.fullmatch(v):
|
||||
seen.add(v)
|
||||
out.append(v)
|
||||
return out[:MAX_MIRROR_TODO_LISTS]
|
||||
|
||||
|
||||
def seed_rotation_assignee(task_data: dict[str, Any]) -> None:
|
||||
"""Ensure a rotation task always carries an effective assignee.
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
|
||||
from ..const import (
|
||||
BATTERY_RECOVERED_PERCENT_RANGE,
|
||||
CONF_ACTION_COMPLETE_ENABLED,
|
||||
CONF_ACTION_SKIP_ENABLED,
|
||||
CONF_ACTION_SNOOZE_ENABLED,
|
||||
@@ -45,6 +46,7 @@ from ..const import (
|
||||
CONF_ARCHIVE_ONEOFF_DAYS,
|
||||
CONF_BATTERY_LIFETIME_MONTHS,
|
||||
CONF_BATTERY_LOW_PERCENT,
|
||||
CONF_BATTERY_RECOVERED_PERCENT,
|
||||
CONF_BUDGET_ALERT_THRESHOLD,
|
||||
CONF_BUDGET_ALERTS_ENABLED,
|
||||
CONF_BUDGET_CURRENCY,
|
||||
@@ -77,6 +79,7 @@ from ..const import (
|
||||
CONF_OPERATOR_WRITE_ENABLED,
|
||||
CONF_PANEL_ENABLED,
|
||||
CONF_PANEL_TITLE,
|
||||
CONF_PART_SEARCH_URL_TEMPLATE,
|
||||
CONF_QUIET_HOURS_ENABLED,
|
||||
CONF_QUIET_HOURS_END,
|
||||
CONF_QUIET_HOURS_START,
|
||||
@@ -91,6 +94,7 @@ from ..const import (
|
||||
CONF_WEEKLY_DIGEST_ENABLED,
|
||||
DEFAULT_ARCHIVE_ONEOFF_DAYS,
|
||||
DEFAULT_BATTERY_LOW_PERCENT,
|
||||
DEFAULT_BATTERY_RECOVERED_PERCENT,
|
||||
DEFAULT_BUDGET_CURRENCY,
|
||||
DEFAULT_CONSUMABLE_THRESHOLD,
|
||||
DEFAULT_CURRENCY_DECIMALS,
|
||||
@@ -103,6 +107,7 @@ from ..const import (
|
||||
DEFAULT_WARNING_DAYS,
|
||||
DEFAULT_WARRANTY_REMINDER_DAYS,
|
||||
MAX_NOTIFY_EXTRA_DATA_LENGTH,
|
||||
MAX_PART_SEARCH_URL_TEMPLATE_LENGTH,
|
||||
)
|
||||
|
||||
|
||||
@@ -128,6 +133,11 @@ SETTING_SPECS: tuple[SettingSpec, ...] = (
|
||||
# #146: household "low" floors (percent) for discovery and the battery fleet.
|
||||
SettingSpec(CONF_DEFAULT_CONSUMABLE_THRESHOLD, int, DEFAULT_CONSUMABLE_THRESHOLD, int_range=(1, 90)),
|
||||
SettingSpec(CONF_BATTERY_LOW_PERCENT, int, DEFAULT_BATTERY_LOW_PERCENT, int_range=(1, 90)),
|
||||
# #180: a low battery stays low until its level rises ABOVE this (hysteresis).
|
||||
SettingSpec(CONF_BATTERY_RECOVERED_PERCENT, int, DEFAULT_BATTERY_RECOVERED_PERCENT, int_range=BATTERY_RECOVERED_PERCENT_RANGE),
|
||||
# D#182: shopping-search URL with a {q} placeholder ("" = automatic by
|
||||
# country/language); the {q} requirement is a bespoke rule in dashboard.py.
|
||||
SettingSpec(CONF_PART_SEARCH_URL_TEMPLATE, str, "", max_len=MAX_PART_SEARCH_URL_TEMPLATE_LENGTH),
|
||||
# D#162 follow-up: {battery type: typical lifetime in months} — the forecast
|
||||
# anchor for batteries without a level sensor (sanitised in dashboard.py).
|
||||
SettingSpec(CONF_BATTERY_LIFETIME_MONTHS, dict, {}),
|
||||
|
||||
Reference in New Issue
Block a user