91 files
This commit is contained in:
@@ -131,6 +131,14 @@ class BatteryOverview:
|
||||
total: int = 0
|
||||
low: list[dict[str, Any]] = field(default_factory=list)
|
||||
soon: list[dict[str, Any]] = field(default_factory=list)
|
||||
# EVERY tracked battery, each tagged low / soon / ok, device-name sorted.
|
||||
#
|
||||
# low and soon answer "what needs doing"; this answers "what is the fleet
|
||||
# watching". Without it a device that is perfectly healthy appears nowhere
|
||||
# in the payload, so it could only be excluded once it had already gone low
|
||||
# — which is exactly when it is being noisy, and after the fleet task may
|
||||
# have auto-completed and dropped it from the list again (discussion #113).
|
||||
all: list[dict[str, Any]] = field(default_factory=list)
|
||||
# Grouped quantities by canonical type.
|
||||
needs_now: OrderedDict[str, int] = field(default_factory=OrderedDict)
|
||||
needs_soon: OrderedDict[str, int] = field(default_factory=OrderedDict)
|
||||
@@ -167,6 +175,8 @@ def build_overview(
|
||||
A battery already low is never double-counted into soon.
|
||||
* ``needs_now`` / ``needs_soon`` = summed quantities per type — the shopping
|
||||
grouping ("2× AA, 4× AAA").
|
||||
* ``all`` = every battery with its status, so a healthy device can be
|
||||
excluded BEFORE it ever becomes noisy.
|
||||
"""
|
||||
ov = BatteryOverview(total=len(batteries))
|
||||
types_seen: OrderedDict[str, None] = OrderedDict()
|
||||
@@ -174,16 +184,20 @@ def build_overview(
|
||||
for bat in sorted(batteries, key=lambda b: b.device_name.lower()):
|
||||
t = _norm_type(bat.battery_type)
|
||||
types_seen[t] = None
|
||||
pred = _predicted_date(bat)
|
||||
days = (pred - today).days if pred is not None else None
|
||||
if bat.low:
|
||||
ov.low.append(_row(bat, t, None))
|
||||
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), "status": "low"})
|
||||
continue
|
||||
pred = _predicted_date(bat)
|
||||
if pred is not None:
|
||||
days = (pred - today).days
|
||||
if days <= horizon_days:
|
||||
ov.soon.append(_row(bat, t, days))
|
||||
ov.needs_soon[t] = ov.needs_soon.get(t, 0) + bat.quantity
|
||||
if days is not None and days <= horizon_days:
|
||||
ov.soon.append(_row(bat, t, days))
|
||||
ov.needs_soon[t] = ov.needs_soon.get(t, 0) + bat.quantity
|
||||
ov.all.append({**_row(bat, t, days), "status": "soon"})
|
||||
continue
|
||||
ov.all.append({**_row(bat, t, days), "status": "ok"})
|
||||
|
||||
ov.soon.sort(key=lambda r: r["days_until"] if r["days_until"] is not None else 1 << 30)
|
||||
ov.types = sorted(types_seen)
|
||||
|
||||
@@ -170,6 +170,113 @@ def _type_part(btype: str, total_qty: int, lang: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
# ── retranslating the seeded texts (issue #115) ────────────────────────────
|
||||
#
|
||||
# Setup writes the object/task/part names and notes ONCE, in the language that
|
||||
# happened to be current — and nothing ever touched them again, so a fleet set
|
||||
# up before localized seeding existed (v2.38) keeps English notes forever while
|
||||
# every runtime string around them is translated. The panel's runtime strings
|
||||
# follow the UI language; these stored ones can only follow it if something
|
||||
# rewrites them.
|
||||
#
|
||||
# The rule that makes rewriting safe: a stored text is only replaced when it
|
||||
# EXACTLY matches one of the known template variants (any language), i.e. the
|
||||
# user never edited it. Anything the user typed stays.
|
||||
|
||||
|
||||
def _template_variants(text_en: str) -> set[str]:
|
||||
"""The English template plus every translation of it."""
|
||||
from ..templates_i18n import _T
|
||||
|
||||
return {text_en} | set(_T.get(text_en, {}).values())
|
||||
|
||||
|
||||
def _extract_placeholder(stored: str, text_en: str, placeholder: str) -> str | None:
|
||||
"""The placeholder's value, if ``stored`` matches any variant of the template.
|
||||
|
||||
``"AA-batteri"`` against ``"{type} battery"`` finds the Danish variant
|
||||
``"{type}-batteri"``, matches prefix/suffix and returns ``"AA"``. Returns
|
||||
None when no variant matches — the user rewrote the text.
|
||||
"""
|
||||
token = "{" + placeholder + "}"
|
||||
for variant in _template_variants(text_en):
|
||||
prefix, sep, suffix = variant.partition(token)
|
||||
if not sep:
|
||||
continue
|
||||
if stored.startswith(prefix) and stored.endswith(suffix) and len(stored) > len(prefix) + len(suffix):
|
||||
return stored[len(prefix) : len(stored) - len(suffix)]
|
||||
return None
|
||||
|
||||
|
||||
def retranslate_seeded_texts(hass: HomeAssistant, entry: ConfigEntry, lang: str) -> bool:
|
||||
"""Bring untouched seeded texts into ``lang``. Returns True when changed.
|
||||
|
||||
Runs at every fleet-entry setup: cheap string comparisons, and idempotent —
|
||||
once the texts are in the current language they match that language's
|
||||
variant and are rewritten to themselves.
|
||||
|
||||
The boot-time caller passes the SERVER language, so untouched seeded texts
|
||||
converge on it — the same convention notifications and digests follow,
|
||||
because stored data is shared by every user of the instance. Seeding still
|
||||
honours the caller's UI language for the immediate result; if the two
|
||||
differ, the next reload converges.
|
||||
"""
|
||||
from ..templates import localize_template_text
|
||||
|
||||
changed = False
|
||||
data = dict(entry.data)
|
||||
|
||||
def _localized(text_en: str) -> str:
|
||||
return localize_template_text(text_en, lang) or text_en
|
||||
|
||||
obj = dict(data.get(CONF_OBJECT) or {})
|
||||
if obj.get("name") in _template_variants("Battery Fleet") and obj.get("name") != _localized("Battery Fleet"):
|
||||
obj["name"] = _localized("Battery Fleet")
|
||||
data[CONF_OBJECT] = obj
|
||||
changed = True
|
||||
|
||||
tasks = dict(data.get(CONF_TASKS) or {})
|
||||
for task_id, task in tasks.items():
|
||||
if not task.get(TASK_FLAG):
|
||||
continue
|
||||
new_task = dict(task)
|
||||
if new_task.get("name") in _template_variants("Replace low batteries"):
|
||||
new_task["name"] = _localized("Replace low batteries")
|
||||
notes_en = (
|
||||
"Aggregate battery check. The detail view lists which devices are low "
|
||||
"and which battery types to buy."
|
||||
)
|
||||
if new_task.get("notes") in _template_variants(notes_en):
|
||||
new_task["notes"] = _localized(notes_en)
|
||||
if new_task != task:
|
||||
tasks[task_id] = new_task
|
||||
data[CONF_TASKS] = tasks
|
||||
changed = True
|
||||
|
||||
parts = dict(data.get(CONF_PARTS) or {})
|
||||
for part_id, part in parts.items():
|
||||
if not str(part_id).startswith("batt_"):
|
||||
continue
|
||||
new_part = dict(part)
|
||||
btype = _extract_placeholder(str(new_part.get("name") or ""), "{type} battery", "type")
|
||||
if btype is not None:
|
||||
new_part["name"] = (_localized("{type} battery")).format(type=btype)
|
||||
months = _extract_placeholder(
|
||||
str(new_part.get("notes") or ""), "Typical service life ~{months} months.", "months"
|
||||
)
|
||||
if months is not None:
|
||||
new_part["notes"] = (_localized("Typical service life ~{months} months.")).format(months=months)
|
||||
if new_part != part:
|
||||
parts[part_id] = new_part
|
||||
data[CONF_PARTS] = parts
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
title = (data.get(CONF_OBJECT) or {}).get("name") or entry.title
|
||||
hass.config_entries.async_update_entry(entry, data=data, title=title)
|
||||
return changed
|
||||
|
||||
|
||||
def replaced_button_for(battery_plus_entity_id: str) -> str:
|
||||
"""The Battery Notes 'replaced' button entity id for a battery_plus sensor.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user