186 files

This commit is contained in:
Home Assistant Version Control
2026-09-14 12:56:59 +00:00
parent 6be1e92941
commit bd8280defe
186 changed files with 14904 additions and 1087 deletions
@@ -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, {}),