198 files
This commit is contained in:
@@ -40,6 +40,8 @@ from typing import Any, cast
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from ..const import DEFAULT_BATTERY_LOW_PERCENT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# The typical-lifetime table, the household overrides and the learned values
|
||||
@@ -51,6 +53,7 @@ from .battery_lifetime import (
|
||||
has_type_forecast,
|
||||
is_rechargeable_type,
|
||||
is_shoppable_type,
|
||||
is_swappable_cell,
|
||||
lifetime_resolver,
|
||||
observe_replacements,
|
||||
table_lifetime_months,
|
||||
@@ -521,12 +524,23 @@ def get_battery_auto_record_recovery(hass: HomeAssistant) -> bool:
|
||||
# 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}})
|
||||
# #205: each entry also keeps the low threshold it latched at. A threshold
|
||||
# lowered since then (the household's battery_low_percent, a Battery Notes
|
||||
# threshold) is a decision, not a hover: a battery above the NEW threshold
|
||||
# is released at once instead of waiting for the recovered level, which
|
||||
# kept 15 % batteries on the list after the floor went from 20 % to 10 %.
|
||||
#
|
||||
# Persisted on the fleet task's Store state ({entity_id: {at, last_replaced, threshold}})
|
||||
# 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"
|
||||
# What a latch from before #205 (no threshold of its own) is taken to have
|
||||
# latched at: the household default, the floor almost every such latch
|
||||
# was set under. (A floor lowered long before leaves such a latch released
|
||||
# once it reads above it; a battery that is really low latches again.)
|
||||
_LEGACY_LATCH_THRESHOLD = float(DEFAULT_BATTERY_LOW_PERCENT)
|
||||
|
||||
|
||||
def sanitize_low_latch(raw: Any) -> dict[str, dict[str, Any]]:
|
||||
@@ -556,6 +570,9 @@ def sanitize_low_latch(raw: Any) -> dict[str, dict[str, Any]]:
|
||||
continue
|
||||
last = entry.get("last_replaced")
|
||||
out[key] = {"at": at.strip()[:40], "last_replaced": last[:40] if isinstance(last, str) else None}
|
||||
threshold = entry.get("threshold")
|
||||
if isinstance(threshold, (int, float)) and not isinstance(threshold, bool) and 0 <= threshold <= 100:
|
||||
out[key]["threshold"] = float(threshold)
|
||||
return out
|
||||
|
||||
|
||||
@@ -587,7 +604,10 @@ def apply_low_latch(
|
||||
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.
|
||||
``recovered`` or a replacement was recorded, else held low;
|
||||
* latched + the battery's low threshold lowered since it latched
|
||||
(#205) and the level above the new one → released (not a recovery:
|
||||
it never reaches ``released_by_level``, nothing is recorded).
|
||||
|
||||
``released_by_level`` (#181 follow-up) collects the ids released by their
|
||||
LEVEL alone — no newer replacement date on the note — which is the
|
||||
@@ -604,9 +624,15 @@ def apply_low_latch(
|
||||
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
|
||||
threshold = float(bat.low_threshold)
|
||||
if bat.low:
|
||||
if entry is None or _replaced_since(last, entry):
|
||||
latch[bat.entity_id] = {"at": now_iso, "last_replaced": last}
|
||||
latch[bat.entity_id] = {"at": now_iso, "last_replaced": last, "threshold": threshold}
|
||||
changed = True
|
||||
elif entry.get("threshold") != threshold:
|
||||
# Low at the CURRENT threshold: the episode belongs to it
|
||||
# (a rebound after this is held, as always).
|
||||
latch[bat.entity_id] = {**entry, "threshold": threshold}
|
||||
changed = True
|
||||
continue
|
||||
if entry is None:
|
||||
@@ -617,6 +643,15 @@ def apply_low_latch(
|
||||
del latch[bat.entity_id]
|
||||
changed = True
|
||||
continue
|
||||
latched_at = entry.get("threshold")
|
||||
if not isinstance(latched_at, (int, float)):
|
||||
latched_at = max(_LEGACY_LATCH_THRESHOLD, threshold)
|
||||
latch[bat.entity_id] = {**entry, "threshold": latched_at}
|
||||
changed = True
|
||||
if bat.available and bat.level is not None and threshold < latched_at and bat.level > threshold:
|
||||
del latch[bat.entity_id]
|
||||
changed = True
|
||||
continue
|
||||
bat.low = True
|
||||
bat.latched = True
|
||||
known = {b.entity_id for b in batteries}
|
||||
@@ -1590,6 +1625,24 @@ def discover_battery_types(hass: HomeAssistant) -> OrderedDict[str, int]:
|
||||
return OrderedDict(sorted(totals.items()))
|
||||
|
||||
|
||||
def discover_rechargeable_cells(hass: HomeAssistant) -> OrderedDict[str, int]:
|
||||
"""Rechargeable cell types across the fleet → total quantity (#208).
|
||||
|
||||
Kept apart from :func:`discover_battery_types` (cells anyone BUYS): a
|
||||
swappable rechargeable cell ("AAA NiMH", "18650") gets a reusable part
|
||||
instead — no reorder threshold, no buy link; a swap takes charged cells
|
||||
and puts the empty ones on the "to charge" pile. A pack built into the
|
||||
device gets nothing.
|
||||
"""
|
||||
totals: OrderedDict[str, int] = OrderedDict()
|
||||
for bat in read_batteries(hass):
|
||||
if not is_swappable_cell(bat.battery_type):
|
||||
continue
|
||||
t = canonical_type(bat.battery_type)
|
||||
totals[t] = totals.get(t, 0) + bat.quantity
|
||||
return OrderedDict(sorted(totals.items()))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_HORIZON_DAYS",
|
||||
"LOW_LATCH_KEY",
|
||||
@@ -1606,6 +1659,7 @@ __all__ = [
|
||||
"canonical_type",
|
||||
"compute_overview",
|
||||
"discover_battery_types",
|
||||
"discover_rechargeable_cells",
|
||||
"fleet_due_without_sensor",
|
||||
"fleet_excluded_entities",
|
||||
"fleet_included_entities",
|
||||
|
||||
@@ -37,6 +37,8 @@ from ..const import (
|
||||
from .battery_fleet import (
|
||||
canonical_type,
|
||||
discover_battery_types,
|
||||
discover_rechargeable_cells,
|
||||
is_rechargeable_type,
|
||||
lifetime_months,
|
||||
note_sibling_entity,
|
||||
read_batteries,
|
||||
@@ -87,6 +89,9 @@ async def async_setup_battery_fleet(hass: HomeAssistant, language: str | None =
|
||||
|
||||
lang = normalize_language_code(language) if language else normalize_language(hass)
|
||||
types = discover_battery_types(hass) # {TYPE: total_qty}
|
||||
# #208: rechargeable cells get a reusable part; it starts untracked — a
|
||||
# device charged in place must not grow a "to charge" pile.
|
||||
cells = discover_rechargeable_cells(hass)
|
||||
|
||||
existing = find_fleet_entry(hass)
|
||||
if existing is not None:
|
||||
@@ -97,7 +102,7 @@ async def async_setup_battery_fleet(hass: HomeAssistant, language: str | None =
|
||||
# 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)
|
||||
added_pids = _reconcile_type_parts(hass, existing, {**types, **cells}, 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
|
||||
# and never flags for reorder. Found on a real fleet: types added by
|
||||
@@ -108,7 +113,8 @@ async def async_setup_battery_fleet(hass: HomeAssistant, language: str | None =
|
||||
store = getattr(rd, "store", None) if rd else None
|
||||
if store is not None:
|
||||
for pid in added_pids:
|
||||
store.set_part_stock(pid, 0)
|
||||
if not _is_cell_part(pid):
|
||||
store.set_part_stock(pid, 0)
|
||||
await store.async_save()
|
||||
repaired = await _reconcile_fleet_task(hass, existing, lang)
|
||||
if migrated and not repaired:
|
||||
@@ -138,7 +144,7 @@ async def async_setup_battery_fleet(hass: HomeAssistant, language: str | None =
|
||||
obj[OBJECT_FLAG] = True
|
||||
new_data[CONF_OBJECT] = obj
|
||||
parts: dict[str, dict[str, Any]] = {}
|
||||
for btype, total_qty in types.items():
|
||||
for btype, total_qty in {**types, **cells}.items():
|
||||
part = normalize_part(_type_part(btype, total_qty, lang))
|
||||
parts[part["id"]] = part
|
||||
new_data[CONF_PARTS] = parts
|
||||
@@ -149,7 +155,8 @@ async def async_setup_battery_fleet(hass: HomeAssistant, language: str | None =
|
||||
store = getattr(rd, "store", None) if rd else None
|
||||
if store is not None:
|
||||
for pid in parts:
|
||||
store.set_part_stock(pid, 0)
|
||||
if not _is_cell_part(pid):
|
||||
store.set_part_stock(pid, 0)
|
||||
await store.async_save()
|
||||
|
||||
# The single aggregate task, triggered by the global low-count sensor.
|
||||
@@ -218,8 +225,18 @@ def _type_part(btype: str, total_qty: int, lang: str) -> dict[str, Any]:
|
||||
"""
|
||||
from ..templates import localize_template_text
|
||||
|
||||
threshold = max(2, total_qty)
|
||||
name_tpl = localize_template_text("{type} battery", lang) or "{type} battery"
|
||||
if is_rechargeable_type(btype):
|
||||
# #208: charged, not bought — a reusable part with no reorder
|
||||
# threshold and no buy link to nag with.
|
||||
return {
|
||||
"id": f"batt_{btype.lower()}",
|
||||
"name": name_tpl.format(type=btype),
|
||||
"unit": "pcs",
|
||||
"auto_buy_task": False,
|
||||
"reuse": "charge",
|
||||
}
|
||||
threshold = max(2, total_qty)
|
||||
notes_tpl = localize_template_text("Typical service life ~{months} months.", lang) or "Typical service life ~{months} months."
|
||||
return {
|
||||
"id": f"batt_{btype.lower()}",
|
||||
@@ -422,8 +439,9 @@ async def async_consume_type_parts(hass: HomeAssistant, by_type: dict[str, int])
|
||||
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)
|
||||
# A use (#208): a rechargeable cell's empty one waits to be charged;
|
||||
# an untracked part (a pack charged in place) is left alone.
|
||||
if pid in parts and await async_change_part_stock(hass, fleet, pid, delta=-qty, use=True) is not None:
|
||||
consumed[pid] = qty
|
||||
return consumed
|
||||
|
||||
@@ -716,7 +734,7 @@ async def reconcile_fleet_parts_at_start(hass: HomeAssistant, entry: ConfigEntry
|
||||
migrated = await migrate_fleet_part_ids(hass, entry)
|
||||
|
||||
types = discover_battery_types(hass)
|
||||
added = _reconcile_type_parts(hass, entry, types, lang)
|
||||
added = _reconcile_type_parts(hass, entry, {**types, **discover_rechargeable_cells(hass)}, lang)
|
||||
|
||||
pruned = False
|
||||
rd = getattr(entry, "runtime_data", None)
|
||||
@@ -761,7 +779,8 @@ async def reconcile_fleet_parts_at_start(hass: HomeAssistant, entry: ConfigEntry
|
||||
|
||||
if store is not None and (added or pruned):
|
||||
for pid in added:
|
||||
store.set_part_stock(pid, 0)
|
||||
if not _is_cell_part(pid):
|
||||
store.set_part_stock(pid, 0)
|
||||
await store.async_save()
|
||||
return {
|
||||
"added": added,
|
||||
@@ -785,6 +804,11 @@ async def reconcile_fleet_parts_at_start(hass: HomeAssistant, entry: ConfigEntry
|
||||
FLEET_PART_PREFIX = "batt_"
|
||||
|
||||
|
||||
def _is_cell_part(part_id: str) -> bool:
|
||||
"""A rechargeable cell's part (#208): it starts untracked."""
|
||||
return part_id.startswith(FLEET_PART_PREFIX) and is_rechargeable_type(part_id[len(FLEET_PART_PREFIX) :])
|
||||
|
||||
|
||||
def canonical_part_id(part_id: str) -> str | None:
|
||||
"""``batt_<label>`` → ``batt_<canonical label>``; None when the id is not
|
||||
a fleet type-part id or already canonical."""
|
||||
|
||||
@@ -53,8 +53,9 @@ LINKS_NAME = "Links.txt"
|
||||
BLOB_DIR = "blobs/"
|
||||
ARCHIVE_VERSION = 2
|
||||
# Cap a single archive import so a crafted ZIP can't exhaust memory/disk. A
|
||||
# real documents backup is dominated by the blobs, already capped at 25 MB
|
||||
# each × 100 docs/object — this is a coarse whole-archive ceiling on top.
|
||||
# real documents backup is dominated by the blobs, already capped at
|
||||
# MAX_DOC_BYTES each × 100 docs/object — this is a coarse whole-archive
|
||||
# ceiling on top.
|
||||
MAX_ARCHIVE_BYTES = 500 * 1024 * 1024 # 500 MB uncompressed-blob budget
|
||||
MAX_MANIFEST_BYTES = 16 * 1024 * 1024 # the manifest is metadata only
|
||||
MAX_ARCHIVE_MEMBERS = 20000 # ceiling on ZIP entry count (blobs cap at 100/obj)
|
||||
|
||||
@@ -16,12 +16,13 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import io
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import IO, TYPE_CHECKING, Any
|
||||
from uuid import uuid4
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -53,8 +54,13 @@ BLOBS_SUBDIR = "blobs"
|
||||
DOC_CATEGORIES = ("manual", "warranty", "invoice", "spare_parts", "photo", "other")
|
||||
|
||||
# Per-file cap — guards against runaway backup growth (docs live in /config and
|
||||
# are therefore duplicated into every retained backup).
|
||||
MAX_DOC_BYTES = 25 * 1024 * 1024 # 25 MB
|
||||
# are therefore duplicated into every retained backup). Was 25 MB; a scanned
|
||||
# manual is easily 45 MB (#209), and since a file is never held in memory
|
||||
# whole (copied and hashed in pieces, served from disk) the cap is about
|
||||
# backups only.
|
||||
MAX_DOC_BYTES = 100 * 1024 * 1024 # 100 MB
|
||||
# A stored file is copied and hashed in pieces of this size.
|
||||
_COPY_CHUNK_BYTES = 1024 * 1024
|
||||
|
||||
KIND_FILE = "file"
|
||||
KIND_WEBLINK = "weblink"
|
||||
@@ -337,7 +343,7 @@ class DocumentStore:
|
||||
self,
|
||||
object_id: str,
|
||||
*,
|
||||
content: bytes,
|
||||
content: bytes | IO[bytes],
|
||||
filename: str,
|
||||
mime: str,
|
||||
title: str | None = None,
|
||||
@@ -346,12 +352,21 @@ class DocumentStore:
|
||||
) -> dict[str, Any]:
|
||||
"""Store an uploaded file (content-addressed + deduped) for an object.
|
||||
|
||||
``content`` is the bytes, or a binary file read from where it stands
|
||||
(the upload view hands over the temporary file aiohttp spooled the
|
||||
upload into): either way it is copied to disk and hashed in pieces,
|
||||
so a 100 MB manual never sits in memory whole. Raises
|
||||
``ValueError("file_too_large")`` past MAX_DOC_BYTES and
|
||||
``ValueError("too_many_documents")`` at the per-object cap.
|
||||
|
||||
Returns the new document dict plus ``deduped`` (the blob already existed)
|
||||
and ``duplicate_in_object`` (the id of an existing doc on the *same*
|
||||
object with identical content, if any — the caller surfaces a hint).
|
||||
"""
|
||||
if len(content) > MAX_DOC_BYTES:
|
||||
raise ValueError("file_too_large")
|
||||
if isinstance(content, bytes):
|
||||
if len(content) > MAX_DOC_BYTES:
|
||||
raise ValueError("file_too_large")
|
||||
content = io.BytesIO(content)
|
||||
|
||||
# Per-object document cap — a runaway upload loop must not be able to
|
||||
# bloat the (single, global) documents store without bound.
|
||||
@@ -360,7 +375,7 @@ class DocumentStore:
|
||||
raise ValueError("too_many_documents")
|
||||
|
||||
async with self._blob_lock:
|
||||
digest, wrote_new = await self.hass.async_add_executor_job(self._store_blob_sync, content)
|
||||
digest, wrote_new, size = await self.hass.async_add_executor_job(self._store_stream_sync, content, MAX_DOC_BYTES)
|
||||
|
||||
# Register / adopt the blob and bump its refcount — in the same
|
||||
# critical section as the write, so a concurrent last-reference
|
||||
@@ -368,7 +383,7 @@ class DocumentStore:
|
||||
blob = self.blobs.get(digest)
|
||||
deduped = blob is not None
|
||||
if blob is None:
|
||||
blob = {"size": len(content), "mime": mime, "refcount": 0}
|
||||
blob = {"size": size, "mime": mime, "refcount": 0}
|
||||
self.blobs[digest] = blob
|
||||
blob["refcount"] += 1
|
||||
|
||||
@@ -385,7 +400,7 @@ class DocumentStore:
|
||||
"title": title or filename,
|
||||
"filename": filename,
|
||||
"mime": mime,
|
||||
"size": len(content),
|
||||
"size": size,
|
||||
"tags": list(tags or []),
|
||||
"description": self.clean_description(description),
|
||||
"task_ids": [],
|
||||
@@ -413,21 +428,44 @@ class DocumentStore:
|
||||
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()
|
||||
path = self.blob_path(digest)
|
||||
if path.exists():
|
||||
if self.blob_path(digest).exists():
|
||||
return digest, False
|
||||
digest, wrote_new, _size = self._store_stream_sync(io.BytesIO(content))
|
||||
return digest, wrote_new
|
||||
|
||||
def _store_stream_sync(self, source: IO[bytes], max_bytes: int | None = None) -> tuple[str, bool, int]:
|
||||
"""Copy ``source`` into a blob, hashing it on the way, in pieces.
|
||||
|
||||
Returns ``(digest, wrote_new, size)``. The copy lands in a temporary
|
||||
file next to the blobs and is renamed to its digest only when that
|
||||
blob is absent. Past ``max_bytes`` it stops and raises
|
||||
``ValueError("file_too_large")``; the temporary file never outlives
|
||||
the call.
|
||||
"""
|
||||
self._blobs_dir.mkdir(parents=True, exist_ok=True)
|
||||
# A temp name of its own per write: two uploads of the same content
|
||||
# shared "<digest>.tmp", and the second os.replace found it already
|
||||
# moved away — a 500 for a perfectly good upload (bug audit
|
||||
# 2026-09-26). Replacing an identical blob is harmless.
|
||||
tmp = path.with_name(f"{path.name}.{uuid4().hex}.tmp")
|
||||
# 2026-09-26). Not a 64-hex name, so the orphan scan never sees it.
|
||||
tmp = self._blobs_dir / f"upload.{uuid4().hex}.tmp"
|
||||
hasher = hashlib.sha256()
|
||||
size = 0
|
||||
try:
|
||||
tmp.write_bytes(content)
|
||||
with tmp.open("wb") as out:
|
||||
while chunk := source.read(_COPY_CHUNK_BYTES):
|
||||
size += len(chunk)
|
||||
if max_bytes is not None and size > max_bytes:
|
||||
raise ValueError("file_too_large")
|
||||
hasher.update(chunk)
|
||||
out.write(chunk)
|
||||
digest = hasher.hexdigest()
|
||||
path = self.blob_path(digest)
|
||||
if path.exists():
|
||||
return digest, False, size
|
||||
os.replace(tmp, path) # atomic
|
||||
return digest, True, size
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
return digest, True
|
||||
|
||||
async def async_add_weblink(
|
||||
self,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Iterable, Mapping
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -40,6 +40,7 @@ from ..const import (
|
||||
MaintenanceStatus,
|
||||
)
|
||||
from .aggregate import priority_rank
|
||||
from .assignees import task_user_ids
|
||||
from .global_options import get_global_options, global_option
|
||||
from .i18n import format_text, normalize_language
|
||||
from .notification_gates import STATUS_ENABLED_KEYS, status_reminder_enabled, task_may_notify
|
||||
@@ -1337,22 +1338,14 @@ class NotificationManager:
|
||||
|
||||
The status-change and lead-reminder paths carried drifting copies of
|
||||
this block — one logged the per-user fallback, the other was silent.
|
||||
Without a responsible person, a task shared with people reaches each
|
||||
of their phones (discussion #110: "shared, no rotation" notified
|
||||
only the global service, so one partner never heard of it).
|
||||
"""
|
||||
target_services: list[str] = []
|
||||
if responsible_user_id:
|
||||
user_services = await get_user_notify_services(self.hass, responsible_user_id)
|
||||
if user_services:
|
||||
target_services = user_services
|
||||
_LOGGER.debug(
|
||||
"Sending notification to user %s services: %s",
|
||||
responsible_user_id,
|
||||
user_services,
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"User %s has no notification services, falling back to global",
|
||||
responsible_user_id,
|
||||
)
|
||||
user_ids = [responsible_user_id] if responsible_user_id else task_user_ids(self._task_config(entry_id, task_id))
|
||||
target_services = await self._user_services(user_ids)
|
||||
if user_ids:
|
||||
_LOGGER.debug("Notification for users %s goes to %s", user_ids, target_services or "the global service")
|
||||
if not target_services and self.notify_service:
|
||||
target_services = [self.notify_service]
|
||||
if not target_services:
|
||||
@@ -1423,6 +1416,16 @@ class NotificationManager:
|
||||
task: Mapping[str, Any] = (entry.data.get(CONF_TASKS) or {}).get(task_id) or {}
|
||||
return task
|
||||
|
||||
async def _user_services(self, user_ids: Iterable[str]) -> list[str]:
|
||||
"""Every notify service of these users' phones, each once."""
|
||||
services: list[str] = []
|
||||
for user_id in user_ids:
|
||||
found = await get_user_notify_services(self.hass, user_id)
|
||||
if not found:
|
||||
_LOGGER.debug("User %s has no notification services", user_id)
|
||||
services.extend(s for s in found if s not in services)
|
||||
return services
|
||||
|
||||
def _opt(self, key: str) -> Any:
|
||||
"""A global setting, or its registry default when unset."""
|
||||
return global_option(self.hass, key)
|
||||
@@ -2526,21 +2529,21 @@ class NotificationManager:
|
||||
self._seeded_entries.discard(entry_id)
|
||||
self._dirty()
|
||||
|
||||
async def async_dismiss_task_notification(self, task_id: str, responsible_user_id: str | None = None) -> None:
|
||||
async def async_dismiss_task_notification(self, task_id: str, user_ids: Iterable[str] = ()) -> None:
|
||||
"""Dismiss a task notification on Companion App devices.
|
||||
|
||||
``clear_notification`` is a legacy mobile_app service feature; the notify
|
||||
*entity* model (notify.send_message) has no equivalent, so an entity-only
|
||||
target can't be dismissed and is simply skipped. Reminders for a task
|
||||
with a responsible person land on THAT person's devices (see
|
||||
_resolve_and_send), so those are cleared too (bug audit 2026-09-12).
|
||||
meant for people (``task_user_ids``: the responsible person, or everyone
|
||||
it is shared with) land on THEIR devices (see _resolve_and_send), so
|
||||
those are cleared too (bug audit 2026-09-12).
|
||||
"""
|
||||
services = [self.notify_service]
|
||||
if responsible_user_id:
|
||||
try:
|
||||
services.extend(await get_user_notify_services(self.hass, responsible_user_id))
|
||||
except Exception: # noqa: BLE001 - a lookup failure must not keep the action from finishing
|
||||
_LOGGER.debug("User notify services unavailable for %s", responsible_user_id, exc_info=True)
|
||||
try:
|
||||
services.extend(await self._user_services(user_ids))
|
||||
except Exception: # noqa: BLE001 - a lookup failure must not keep the action from finishing
|
||||
_LOGGER.debug("User notify services unavailable for %s", user_ids, exc_info=True)
|
||||
tag = task_tag(task_id)
|
||||
for service in dict.fromkeys(s for s in services if s):
|
||||
domain, _, name = service.partition(".")
|
||||
|
||||
@@ -5,6 +5,8 @@ the part model and every rule around it, hass-free so it is trivially
|
||||
unit-testable:
|
||||
|
||||
- normalization/validation of the part dict stored in ``entry.data["parts"]``
|
||||
- reusable parts (#208): a used one is not gone but waits to be ready again
|
||||
(charged, sharpened, cleaned, dried, refilled); it still counts as owned
|
||||
- GTIN validation (GS1 check digit; the GTIN family covers EAN-13/UPC-A/EAN-8)
|
||||
- edge-triggered stock transitions (``low`` / ``out`` / ``restocked``)
|
||||
- the **declarative buy-task reconciler**: an auto "Buy {part}" task exists
|
||||
@@ -46,6 +48,12 @@ MAX_PART_STOCK = 100_000
|
||||
MAX_CONSUMES_PER_TASK = 10
|
||||
MAX_CONSUME_QUANTITY = 10_000
|
||||
|
||||
# #208: what happens to a used reusable part before it is ready again. A
|
||||
# part without ``reuse`` is used up as before. Rechargeable cells and
|
||||
# swappable packs are charged, mower blades sharpened, washable filters
|
||||
# cleaned, desiccant dried, gas bottles refilled; "restore" covers the rest.
|
||||
REUSE_KINDS = ("charge", "sharpen", "clean", "dry", "refill", "restore")
|
||||
|
||||
# Marker on auto-created buy tasks: {"part_id": "..."} — the reconciler
|
||||
# exclusively owns tasks carrying it. Detached (popped) from a COMPLETED buy
|
||||
# task once its part is restocked, so the task survives as a plain done
|
||||
@@ -255,6 +263,15 @@ def _clean_stock(raw: Any, field: str) -> float | int | None:
|
||||
return round_qty(v)
|
||||
|
||||
|
||||
def _clean_reuse(raw: Any) -> str | None:
|
||||
"""One of :data:`REUSE_KINDS`, or None for a part that is used up."""
|
||||
if raw in (None, "", False):
|
||||
return None
|
||||
if raw not in REUSE_KINDS:
|
||||
raise PartValidationError(f"reuse must be one of {', '.join(REUSE_KINDS)}")
|
||||
return str(raw)
|
||||
|
||||
|
||||
def normalize_part(raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Validate + normalize one part definition (the stored static shape).
|
||||
|
||||
@@ -283,6 +300,7 @@ def normalize_part(raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
# #98 follow-up: bought in packages, used and stocked in the unit.
|
||||
"package_size": _clean_package_size(raw.get("package_size")),
|
||||
"auto_buy_task": bool(raw.get("auto_buy_task")),
|
||||
"reuse": _clean_reuse(raw.get("reuse")),
|
||||
# Receipt/datasheet via the refcounted DocumentStore (not inline files).
|
||||
"doc_id": _clean_str(raw.get("doc_id"), "doc_id", 64) or None,
|
||||
}
|
||||
@@ -419,8 +437,21 @@ def map_part_links(
|
||||
# ── Stock rules ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def on_hand(part: Mapping[str, Any], stock: float | None, restoring: float | None = None) -> float | None:
|
||||
"""What the household owns of a part: its stock, and for a reusable part
|
||||
(#208) also the used ones waiting to be ready again. Charging cells are
|
||||
not bought again, so buying, the low/out events and the reorder
|
||||
threshold all read this. None while the stock is not tracked."""
|
||||
if stock is None:
|
||||
return None
|
||||
if part.get("reuse") and restoring:
|
||||
return round_qty(float(stock) + float(restoring))
|
||||
return stock
|
||||
|
||||
|
||||
def part_is_low(part: Mapping[str, Any], stock: float | None) -> bool:
|
||||
"""Tracked stock at/below the reorder threshold."""
|
||||
"""Tracked stock at/below the reorder threshold (pass :func:`on_hand`
|
||||
for a reusable part)."""
|
||||
threshold = part.get("reorder_threshold")
|
||||
return stock is not None and threshold is not None and stock <= float(threshold)
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from uuid import uuid4
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..const import CONF_SAVED_FILTER_VIEWS, DEFAULT_TASK_PRIORITY, MAX_SAVED_VIEWS, MAX_VIEW_NAME_LENGTH, TaskPriority
|
||||
from .assignees import task_is_for
|
||||
from .global_options import get_global_options
|
||||
|
||||
# Closed value sets mirrored from the panel's filter controls. Anything outside
|
||||
@@ -93,7 +94,9 @@ def view_matches_task(filters: Mapping[str, Any], task: Mapping[str, Any]) -> bo
|
||||
if label and label not in (task.get("labels") or []):
|
||||
return False
|
||||
user_id = filters.get("user_id")
|
||||
if user_id and user_id != "current_user" and task.get("responsible_user_id") != user_id:
|
||||
# A person's view holds the tasks meant for them: their own, and shared
|
||||
# ones nobody is responsible for (helpers/assignees, discussion #110).
|
||||
if user_id and user_id != "current_user" and not task_is_for(task, user_id):
|
||||
return False
|
||||
# Tasks without an explicit priority are "normal" everywhere (the model
|
||||
# default) — the filter must see them the same way.
|
||||
|
||||
Reference in New Issue
Block a user