291 files
This commit is contained in:
@@ -50,6 +50,7 @@ from .battery_lifetime import (
|
||||
TYPICAL_LIFETIME_MONTHS,
|
||||
LifetimeInfo,
|
||||
canonical_type,
|
||||
fleet_part_type,
|
||||
has_type_forecast,
|
||||
is_rechargeable_type,
|
||||
is_shoppable_type,
|
||||
@@ -1636,9 +1637,9 @@ def discover_rechargeable_cells(hass: HomeAssistant) -> OrderedDict[str, int]:
|
||||
"""
|
||||
totals: OrderedDict[str, int] = OrderedDict()
|
||||
for bat in read_batteries(hass):
|
||||
if not is_swappable_cell(bat.battery_type):
|
||||
t = fleet_part_type(bat.battery_type) if is_swappable_cell(bat.battery_type) else None
|
||||
if t is None:
|
||||
continue
|
||||
t = canonical_type(bat.battery_type)
|
||||
totals[t] = totals.get(t, 0) + bat.quantity
|
||||
return OrderedDict(sorted(totals.items()))
|
||||
|
||||
@@ -1663,6 +1664,7 @@ __all__ = [
|
||||
"fleet_due_without_sensor",
|
||||
"fleet_excluded_entities",
|
||||
"fleet_included_entities",
|
||||
"fleet_part_type",
|
||||
"fleet_track_self_charging",
|
||||
"get_battery_auto_record_recovery",
|
||||
"get_battery_recovered_percent",
|
||||
|
||||
@@ -38,6 +38,7 @@ from .battery_fleet import (
|
||||
canonical_type,
|
||||
discover_battery_types,
|
||||
discover_rechargeable_cells,
|
||||
fleet_part_type,
|
||||
is_rechargeable_type,
|
||||
lifetime_months,
|
||||
note_sibling_entity,
|
||||
@@ -415,8 +416,9 @@ async def async_mark_replaced(hass: HomeAssistant, entity_ids: list[str] | None
|
||||
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
|
||||
t = fleet_part_type(bat.battery_type)
|
||||
if t is not None:
|
||||
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).
|
||||
@@ -505,7 +507,8 @@ async def async_record_replacement(hass: HomeAssistant, entity_id: str, replaced
|
||||
|
||||
consumed: dict[str, int] = {}
|
||||
if not already:
|
||||
consumed = await async_consume_type_parts(hass, {canonical_type(bat.battery_type): bat.quantity})
|
||||
part_type = fleet_part_type(bat.battery_type)
|
||||
consumed = await async_consume_type_parts(hass, {part_type: bat.quantity} if part_type else {})
|
||||
if found is not None:
|
||||
store, task_id = found
|
||||
recorded[entity_id] = day
|
||||
@@ -753,6 +756,24 @@ async def reconcile_fleet_parts_at_start(hass: HomeAssistant, entry: ConfigEntry
|
||||
if store is not None:
|
||||
store.remove_part("batt_unknown")
|
||||
|
||||
# #208 follow-up: v2.102.0 keyed a 9V cell's part without its size
|
||||
# ("9V NiMH" → batt_nimh, the key of a lock's built-in NiMH pack). No
|
||||
# battery draws from such a part any more; prune it while untouched.
|
||||
stale_cells = [
|
||||
pid
|
||||
for pid, part in parts.items()
|
||||
if _stale_cell_part_untouched(entry, str(pid), part, store.get_part_stock(pid) if store is not None else None)
|
||||
]
|
||||
if stale_cells:
|
||||
for pid in stale_cells:
|
||||
parts.pop(pid)
|
||||
if store is not None:
|
||||
store.remove_part(pid)
|
||||
new_data = dict(entry.data)
|
||||
new_data[CONF_PARTS] = parts
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
pruned = True
|
||||
|
||||
# #148 follow-up (v2.70.0 aftermath): the prune must not leave the
|
||||
# part's stock sensor behind as an unavailable orphan in the entity
|
||||
# registry — the official ws_delete_part removes that entry, the v2.70.0
|
||||
@@ -811,13 +832,16 @@ def _is_cell_part(part_id: str) -> bool:
|
||||
|
||||
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."""
|
||||
a fleet type-part id or already canonical. The key is the one the
|
||||
batteries draw from (:func:`fleet_part_type`): a 9V cell keeps its size,
|
||||
and a pack's part (none is minted) is not re-keyed."""
|
||||
if not part_id.startswith(FLEET_PART_PREFIX):
|
||||
return None
|
||||
label = part_id[len(FLEET_PART_PREFIX) :]
|
||||
if not label:
|
||||
key = fleet_part_type(label) if label else None
|
||||
if not key:
|
||||
return None
|
||||
new_id = f"{FLEET_PART_PREFIX}{canonical_type(label).lower()}"
|
||||
new_id = f"{FLEET_PART_PREFIX}{key.lower()}"
|
||||
return None if new_id == part_id else new_id
|
||||
|
||||
|
||||
@@ -990,13 +1014,16 @@ async def migrate_fleet_part_ids(hass: HomeAssistant, entry: ConfigEntry) -> dic
|
||||
name = str(gone.get("name") or "")
|
||||
label = _extract_placeholder(name, "{type} battery", "type")
|
||||
if label is not None and label.lower() == old[len(FLEET_PART_PREFIX) :]:
|
||||
gone["name"] = name.replace(label, canonical_type(label), 1)
|
||||
gone["name"] = name.replace(label, fleet_part_type(label) or canonical_type(label), 1)
|
||||
parts[new] = gone
|
||||
_LOGGER.info("Battery fleet: renamed part %s to %s (canonical type)", old, new)
|
||||
if store is not None:
|
||||
old_stock, new_stock = store.get_part_stock(old), store.get_part_stock(new)
|
||||
piles = store.get_part_restoring(old) + store.get_part_restoring(new)
|
||||
if old_stock is not None or new_stock is not None:
|
||||
store.set_part_stock(new, (old_stock or 0) + (new_stock or 0))
|
||||
# #208: the used ones waiting to be charged move along.
|
||||
store.set_part_restoring(new, piles)
|
||||
store.remove_part(old)
|
||||
|
||||
# 2. The fleet's own tasks (links + auto-buy refs) and its history.
|
||||
@@ -1056,6 +1083,25 @@ async def migrate_fleet_part_ids(hass: HomeAssistant, entry: ConfigEntry) -> dic
|
||||
_LEGACY_UNKNOWN_NOTES = re.compile(r"Typical service life ~\d+ months( \(editorial\))?\.")
|
||||
|
||||
|
||||
def _stale_cell_part_untouched(entry: ConfigEntry, part_id: str, part: dict[str, Any], stock: Any) -> bool:
|
||||
"""Whether ``part_id`` is a rechargeable cell part no battery draws from
|
||||
(a v2.102.0 9V key without its size) that nobody has touched: never
|
||||
counted, no product data, its seeded name, and no task linking it."""
|
||||
if not _is_cell_part(part_id) or fleet_part_type(part_id[len(FLEET_PART_PREFIX) :]) is not None:
|
||||
return False
|
||||
if stock is not None or part.get("reuse") != "charge":
|
||||
return False
|
||||
if any(part.get(key) for key in ("product_url", "vendor", "mpn", "gtin", "cost", "doc_id", "storage_location", "notes")):
|
||||
return False
|
||||
if _extract_placeholder(str(part.get("name") or ""), "{type} battery", "type") is None:
|
||||
return False
|
||||
for task in (entry.data.get(CONF_TASKS) or {}).values():
|
||||
links = task.get(CONF_TASK_CONSUMES_PARTS)
|
||||
if isinstance(links, list) and any(isinstance(x, dict) and x.get("part_id") == part_id for x in links):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _legacy_unknown_untouched(entry: ConfigEntry, legacy: dict[str, Any], stock: Any) -> bool:
|
||||
"""Whether the legacy "UNKNOWN battery" part still looks exactly the way
|
||||
older versions minted it — the only state the start-up prune may remove.
|
||||
|
||||
@@ -39,6 +39,7 @@ from ..const import (
|
||||
MAX_TEXT_LENGTH,
|
||||
SIGNAL_DOCUMENTS_UPDATED,
|
||||
)
|
||||
from .url_safety import is_safe_link
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .document_text import DocumentTextIndex
|
||||
@@ -569,10 +570,11 @@ class DocumentStore:
|
||||
pages = remap_pages(meta)
|
||||
if meta.get("kind") == KIND_WEBLINK:
|
||||
url = meta.get("url")
|
||||
# Only http(s) links — the add-link WS path enforces the same, so
|
||||
# a crafted export can't smuggle a javascript:/data: URL that the
|
||||
# frontend would later window.open (matches ws_documents_add_link).
|
||||
if not isinstance(url, str) or not url.lower().startswith(("http://", "https://")):
|
||||
# The add-link WS path's rule (#207): web and app links pass, so
|
||||
# an obsidian:// link survives Replace object and a backup; a
|
||||
# crafted export still can't smuggle a javascript:/data: URL that
|
||||
# the frontend would later open.
|
||||
if not isinstance(url, str) or not is_safe_link(url):
|
||||
return 0
|
||||
new_id = uuid4().hex
|
||||
remember(meta, new_id)
|
||||
|
||||
@@ -1446,6 +1446,13 @@ class NotificationManager:
|
||||
(#173), where the event itself is the delivery."""
|
||||
return bool(self.notify_service) or self.event_only
|
||||
|
||||
@property
|
||||
def can_bundle(self) -> bool:
|
||||
"""A bundle is one message for a whole object, sent to the household
|
||||
target. Without one (every reminder goes to the people's own phones)
|
||||
the tasks go out one by one, each to whoever it is meant for."""
|
||||
return self._has_target
|
||||
|
||||
@property
|
||||
def event_only(self) -> bool:
|
||||
"""#165/#173: the user routes every notification through the event."""
|
||||
|
||||
@@ -190,7 +190,17 @@ def discover_problem_sensors(hass: HomeAssistant) -> list[dict[str, Any]]:
|
||||
# accumulated knowledge; the rest is configuration the user set up once and
|
||||
# shouldn't have to redo (consumes_parts is re-validated against the target
|
||||
# object's parts on restore — the object may differ or the part may be gone).
|
||||
_STASHED_TASK_FIELDS = ("notes", "responsible_user_id", "priority", "labels", "consumes_parts")
|
||||
# Who the task is meant for travels as a unit: the responsible person, the
|
||||
# people it is shared with and the rotation between them (D#110).
|
||||
_STASHED_TASK_FIELDS = (
|
||||
"notes",
|
||||
"responsible_user_id",
|
||||
"assignee_pool",
|
||||
"rotation_strategy",
|
||||
"priority",
|
||||
"labels",
|
||||
"consumes_parts",
|
||||
)
|
||||
|
||||
|
||||
def stash_task_config_for_readopt(hass: HomeAssistant, task: dict[str, Any]) -> None:
|
||||
|
||||
Reference in New Issue
Block a user