186 files
This commit is contained in:
@@ -266,6 +266,8 @@ def _build_task_summary(
|
||||
# dialog prefill what was already done.
|
||||
"checklist_progress": task_data.get("checklist_progress", {}),
|
||||
"labels": task_data.get("labels", []),
|
||||
# D#183: external to-do lists the task is mirrored into while due.
|
||||
"mirror_todo_entities": task_data.get("mirror_todo_entities", []),
|
||||
# Payload diet (perf, 2026-08): the LIST response carries only the
|
||||
# most recent entries — at 150+ tasks the full histories dominated
|
||||
# the `objects` payload (407 KB measured at just 8 entries/task) and
|
||||
@@ -395,7 +397,7 @@ def _build_object_response(
|
||||
**part,
|
||||
"stock": stock,
|
||||
"is_low": part_is_low(part, stock),
|
||||
"shopping_url": resolve_shopping_url(part, search_template, lang),
|
||||
"shopping_url": resolve_shopping_url(part, search_template, lang, hass.config.country),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -692,6 +694,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
|
||||
ws_battery_fleet_history,
|
||||
ws_battery_fleet_mark_replaced,
|
||||
ws_battery_fleet_overview,
|
||||
ws_battery_fleet_record_replacement,
|
||||
ws_battery_fleet_set_due_without_sensor,
|
||||
ws_battery_fleet_set_excluded,
|
||||
ws_battery_fleet_set_included,
|
||||
@@ -856,6 +859,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_battery_fleet_status)
|
||||
websocket_api.async_register_command(hass, ws_battery_fleet_setup)
|
||||
websocket_api.async_register_command(hass, ws_battery_fleet_mark_replaced)
|
||||
websocket_api.async_register_command(hass, ws_battery_fleet_record_replacement)
|
||||
websocket_api.async_register_command(hass, ws_battery_fleet_set_excluded)
|
||||
websocket_api.async_register_command(hass, ws_battery_fleet_set_included)
|
||||
websocket_api.async_register_command(hass, ws_battery_fleet_set_track_self_charging)
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from ..const import DOMAIN, MAX_ENTITY_ID_LENGTH
|
||||
from ..helpers.battery_fleet import (
|
||||
@@ -26,6 +27,7 @@ from ..helpers.battery_fleet import (
|
||||
)
|
||||
from ..helpers.battery_fleet_setup import (
|
||||
async_mark_replaced,
|
||||
async_record_replacement,
|
||||
async_setup_battery_fleet,
|
||||
find_fleet_entry,
|
||||
fleet_task_trigger_ok,
|
||||
@@ -234,3 +236,35 @@ async def ws_battery_fleet_mark_replaced(
|
||||
"""Mark batteries replaced (press their button + consume the type-parts)."""
|
||||
result = await async_mark_replaced(hass, msg.get("entity_ids"))
|
||||
connection.send_result(msg["id"], result)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): f"{DOMAIN}/battery_fleet/record_replacement",
|
||||
vol.Required("entity_id"): vol.All(str, vol.Length(max=MAX_ENTITY_ID_LENGTH)),
|
||||
# ISO datetime of the detected swap (the roster's calendar-sync chip).
|
||||
vol.Required("replaced_at"): vol.All(str, vol.Length(max=40)),
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_battery_fleet_record_replacement(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Record a detected-but-unrecorded swap in Battery Notes AND consume the
|
||||
type-part cells like the Replaced action (#181). Returns ``{recorded,
|
||||
already_recorded, consumed: {part_id: qty}}``."""
|
||||
try:
|
||||
result = await async_record_replacement(hass, msg["entity_id"], msg["replaced_at"])
|
||||
except HomeAssistantError as err:
|
||||
code = str(err)
|
||||
if code == "not_found":
|
||||
connection.send_error(msg["id"], "not_found", "Battery not found in the fleet")
|
||||
elif code == "invalid_device":
|
||||
connection.send_error(msg["id"], "invalid_device", "Battery has no device to record on")
|
||||
elif code == "invalid_date":
|
||||
connection.send_error(msg["id"], "invalid_date", "replaced_at is not an ISO datetime")
|
||||
else:
|
||||
connection.send_error(msg["id"], "not_available", "Battery Notes is not available")
|
||||
return
|
||||
connection.send_result(msg["id"], result)
|
||||
|
||||
@@ -31,6 +31,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,
|
||||
@@ -63,6 +64,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,
|
||||
@@ -154,12 +156,22 @@ def _battery_lifetime_catalog(hass: HomeAssistant) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
|
||||
def _search_url_default(hass: HomeAssistant) -> str:
|
||||
"""The shopping-search template in effect when none is set (D#182:
|
||||
country first, UI language second) — the Settings input's placeholder."""
|
||||
from ..helpers.i18n import normalize_language
|
||||
from ..helpers.parts import default_search_template
|
||||
|
||||
return default_search_template(normalize_language(hass), hass.config.country)
|
||||
|
||||
|
||||
def _build_full_settings(
|
||||
options: Mapping[str, Any],
|
||||
*,
|
||||
notify_targets: list[str] | None = None,
|
||||
battery_notes: dict[str, Any] | None = None,
|
||||
battery_lifetimes: list[dict[str, Any]] | None = None,
|
||||
part_search_url_default: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Build a full settings dict from global entry options.
|
||||
|
||||
@@ -167,6 +179,8 @@ def _build_full_settings(
|
||||
``helpers/notify_targets.build_notify_targets``) surfaced under
|
||||
``general.notify_targets`` so the panel picker uses the exact same set as
|
||||
the options flow instead of recomputing it client-side.
|
||||
``part_search_url_default`` is the computed built-in search template
|
||||
(:func:`_search_url_default`), never stored.
|
||||
"""
|
||||
# Every fallback below is the registry default (settings_registry) via
|
||||
# _opt — tests/test_settings_defaults.py pins the echo to that table.
|
||||
@@ -207,6 +221,12 @@ def _build_full_settings(
|
||||
# #146: household "low" floors for discovery + the battery fleet.
|
||||
"default_consumable_threshold": _opt(options, CONF_DEFAULT_CONSUMABLE_THRESHOLD),
|
||||
"battery_low_percent": _opt(options, CONF_BATTERY_LOW_PERCENT),
|
||||
# #180: a low battery counts as replaced only above this level.
|
||||
"battery_recovered_percent": _opt(options, CONF_BATTERY_RECOVERED_PERCENT),
|
||||
# D#182: shopping-search template ("" = automatic) + the automatic
|
||||
# value in effect (computed: country, then UI language).
|
||||
"part_search_url_template": _opt(options, CONF_PART_SEARCH_URL_TEMPLATE),
|
||||
"part_search_url_default": part_search_url_default,
|
||||
# D#162 follow-up: the household's per-type lifetime overrides and,
|
||||
# computed by the caller, the effective lifetime catalog Settings
|
||||
# renders (type, months, source: override / learned / table / default).
|
||||
@@ -311,7 +331,13 @@ async def ws_get_settings(
|
||||
if global_entry is None:
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
_build_full_settings({}, notify_targets=build_notify_targets(hass), battery_notes=bn, battery_lifetimes=_battery_lifetime_catalog(hass)),
|
||||
_build_full_settings(
|
||||
{},
|
||||
notify_targets=build_notify_targets(hass),
|
||||
battery_notes=bn,
|
||||
battery_lifetimes=_battery_lifetime_catalog(hass),
|
||||
part_search_url_default=_search_url_default(hass),
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -323,6 +349,7 @@ async def ws_get_settings(
|
||||
notify_targets=build_notify_targets(hass, current=options.get(CONF_NOTIFY_SERVICE, "")),
|
||||
battery_notes=bn,
|
||||
battery_lifetimes=_battery_lifetime_catalog(hass),
|
||||
part_search_url_default=_search_url_default(hass),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -821,6 +848,17 @@ def sanitize_settings_input(settings_input: dict[str, Any]) -> tuple[dict[str, A
|
||||
return filtered, "invalid_shopping_list_entity"
|
||||
filtered[CONF_SHOPPING_LIST_ENTITY] = raw_ent
|
||||
|
||||
# D#182: the shopping-search template — "" clears (automatic by country /
|
||||
# language); anything else must be an http(s) URL WITH the {q} placeholder,
|
||||
# or the search would open the same page for every part.
|
||||
if CONF_PART_SEARCH_URL_TEMPLATE in filtered:
|
||||
from ..helpers.parts import valid_search_template
|
||||
|
||||
raw_tpl = (filtered[CONF_PART_SEARCH_URL_TEMPLATE] or "").strip()
|
||||
if raw_tpl and not valid_search_template(raw_tpl):
|
||||
return filtered, "invalid_search_template"
|
||||
filtered[CONF_PART_SEARCH_URL_TEMPLATE] = raw_tpl
|
||||
|
||||
# Validate notify_service if provided
|
||||
if CONF_NOTIFY_SERVICE in filtered:
|
||||
from ..config_flow_options_global import validate_notify_service
|
||||
@@ -861,7 +899,7 @@ async def ws_update_global_settings(
|
||||
|
||||
filtered, notify_error = sanitize_settings_input(settings_input)
|
||||
if notify_error:
|
||||
connection.send_error(msg["id"], notify_error, f"Invalid notify service: {notify_error}")
|
||||
connection.send_error(msg["id"], notify_error, f"Invalid setting value: {notify_error}")
|
||||
return
|
||||
if not filtered:
|
||||
connection.send_error(msg["id"], "invalid_input", "No valid setting keys provided")
|
||||
@@ -880,6 +918,7 @@ async def ws_update_global_settings(
|
||||
merged,
|
||||
notify_targets=build_notify_targets(hass, current=merged.get(CONF_NOTIFY_SERVICE, "")),
|
||||
battery_lifetimes=_battery_lifetime_catalog(hass),
|
||||
part_search_url_default=_search_url_default(hass),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -800,6 +800,8 @@ async def ws_import_json(
|
||||
# backup round-trips them (validated/clamped just below).
|
||||
"priority",
|
||||
"labels",
|
||||
# D#183: mirror targets (shape-sanitized below).
|
||||
"mirror_todo_entities",
|
||||
"earliest_completion_days",
|
||||
"ref_no",
|
||||
"on_complete_action",
|
||||
@@ -960,6 +962,17 @@ async def ws_import_json(
|
||||
else:
|
||||
task_data.pop("readings", None)
|
||||
|
||||
# D#183: mirror targets — todo.* ids only, deduped, capped; an
|
||||
# empty result drops the key (same rules as the WS write paths).
|
||||
if task_data.get("mirror_todo_entities") is not None:
|
||||
from ..helpers.sanitize import sanitize_mirror_todo_entities
|
||||
|
||||
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)
|
||||
|
||||
# schedule_time: canonical HH:MM. The options flow's TimeSelector
|
||||
# stores "HH:MM:SS" and the export writes it verbatim — that used
|
||||
# to be DROPPED here (strict HH:MM), so a backup lost the time.
|
||||
|
||||
@@ -32,12 +32,14 @@ from ..const import (
|
||||
MAX_LABEL_LENGTH,
|
||||
MAX_LABELS,
|
||||
MAX_META_LENGTH,
|
||||
MAX_MIRROR_TODO_LISTS,
|
||||
MAX_NAME_LENGTH,
|
||||
MAX_NFC_TAG_LENGTH,
|
||||
MAX_READING_UNIT_LENGTH,
|
||||
MAX_TEXT_LENGTH,
|
||||
MAX_TYPE_LENGTH,
|
||||
MAX_URL_LENGTH,
|
||||
MIRROR_TODO_ENTITY_PATTERN,
|
||||
NOTIFICATION_MANAGER_KEY,
|
||||
HistoryEntryType,
|
||||
)
|
||||
@@ -71,6 +73,7 @@ from .tasks_persist import async_persist_task
|
||||
from .tasks_validation import (
|
||||
_check_nfc_tag_duplicate,
|
||||
_is_safe_url,
|
||||
_mirror_todo_error,
|
||||
_validate_trigger_config,
|
||||
)
|
||||
|
||||
@@ -151,6 +154,8 @@ TASK_UPDATE_FIELD_MAP = {
|
||||
"priority": "priority",
|
||||
"checklist": "checklist",
|
||||
"labels": "labels",
|
||||
# D#183: external to-do lists the task is mirrored into while due.
|
||||
"mirror_todo_entities": "mirror_todo_entities",
|
||||
"schedule_time": "schedule_time",
|
||||
# v1.3.0
|
||||
"on_complete_action": "on_complete_action",
|
||||
@@ -260,6 +265,10 @@ _TASK_CREATE_SCHEMA: dict[Any, Any] = {
|
||||
vol.Optional("labels"): vol.Any(
|
||||
vol.All([vol.All(str, vol.Length(max=MAX_LABEL_LENGTH))], vol.Length(max=MAX_LABELS)), None
|
||||
),
|
||||
# D#183: todo.* entity ids the due task is mirrored into ([] / None = off).
|
||||
vol.Optional("mirror_todo_entities"): vol.Any(
|
||||
vol.All([vol.All(str, vol.Match(MIRROR_TODO_ENTITY_PATTERN))], vol.Length(max=MAX_MIRROR_TODO_LISTS)), None
|
||||
),
|
||||
# HH:MM strict (00–23 : 00–59). None clears the time → midnight semantic.
|
||||
vol.Optional("schedule_time"): vol.Any(
|
||||
vol.All(str, vol.Match(r"^([01]\d|2[0-3]):[0-5]\d$")),
|
||||
@@ -443,6 +452,18 @@ async def ws_create_task(
|
||||
from ..helpers.sanitize import sanitize_labels
|
||||
|
||||
task_data["labels"] = sanitize_labels(msg["labels"])
|
||||
# D#183: mirror targets — shape-sanitized, and our own to-do platform is
|
||||
# refused (it cannot take rows, and mirroring into ourselves is circular).
|
||||
if msg.get("mirror_todo_entities"):
|
||||
from ..helpers.sanitize import sanitize_mirror_todo_entities
|
||||
|
||||
mirrors = sanitize_mirror_todo_entities(msg["mirror_todo_entities"])
|
||||
mirror_err = _mirror_todo_error(hass, mirrors)
|
||||
if mirror_err:
|
||||
connection.send_error(msg["id"], "invalid_mirror_todo", mirror_err)
|
||||
return
|
||||
if mirrors:
|
||||
task_data["mirror_todo_entities"] = mirrors
|
||||
if msg.get("schedule_time"):
|
||||
task_data["schedule_time"] = msg["schedule_time"]
|
||||
# v1.3.0: optional completion-action + quick-defaults. Strict shape
|
||||
@@ -545,6 +566,10 @@ _TASK_UPDATE_SCHEMA: dict[Any, Any] = {
|
||||
vol.Optional("labels"): vol.Any(
|
||||
vol.All([vol.All(str, vol.Length(max=MAX_LABEL_LENGTH))], vol.Length(max=MAX_LABELS)), None
|
||||
),
|
||||
# D#183: see create schema.
|
||||
vol.Optional("mirror_todo_entities"): vol.Any(
|
||||
vol.All([vol.All(str, vol.Match(MIRROR_TODO_ENTITY_PATTERN))], vol.Length(max=MAX_MIRROR_TODO_LISTS)), None
|
||||
),
|
||||
vol.Optional("schedule_time"): vol.Any(
|
||||
vol.All(str, vol.Match(r"^([01]\d|2[0-3]):[0-5]\d$")),
|
||||
None,
|
||||
@@ -620,6 +645,17 @@ async def ws_update_task(
|
||||
connection.send_error(msg["id"], "invalid_url", "Only http/https URLs are allowed")
|
||||
return
|
||||
|
||||
# D#183: mirror targets — same sanitize + own-platform refusal as create;
|
||||
# an empty list clears the field (the raw-field-map lesson).
|
||||
if "mirror_todo_entities" in msg:
|
||||
from ..helpers.sanitize import sanitize_mirror_todo_entities
|
||||
|
||||
msg["mirror_todo_entities"] = sanitize_mirror_todo_entities(msg["mirror_todo_entities"])
|
||||
mirror_err = _mirror_todo_error(hass, msg["mirror_todo_entities"])
|
||||
if mirror_err:
|
||||
connection.send_error(msg["id"], "invalid_mirror_todo", mirror_err)
|
||||
return
|
||||
|
||||
for msg_key, data_key in TASK_UPDATE_FIELD_MAP.items():
|
||||
if msg_key in msg:
|
||||
task[data_key] = msg[msg_key]
|
||||
@@ -653,6 +689,9 @@ async def ws_update_task(
|
||||
task["notify_enabled"] = False
|
||||
else:
|
||||
task.pop("notify_enabled", None)
|
||||
# D#183: an empty mirror list means "off" — drop the key, don't store [].
|
||||
if "mirror_todo_entities" in msg and not msg["mirror_todo_entities"]:
|
||||
task.pop("mirror_todo_entities", None)
|
||||
|
||||
# The loop above copies values verbatim, which is wrong for part links:
|
||||
# `task/create` validates them and `task/update` did not, so an edit could
|
||||
@@ -857,9 +896,12 @@ async def async_delete_task(
|
||||
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
|
||||
# Clean up Store
|
||||
# Clean up Store (a mirrored task takes its to-do rows with it, D#183)
|
||||
store = get_store(hass, entry.entry_id)
|
||||
if store is not None:
|
||||
mirror = hass.data.get(DOMAIN, {}).get("todo_mirror")
|
||||
if mirror is not None and hasattr(mirror, "async_forget_task"):
|
||||
await mirror.async_forget_task(store, task_id)
|
||||
store.remove_task(task_id)
|
||||
await store.async_save()
|
||||
|
||||
|
||||
@@ -79,6 +79,32 @@ def _check_nfc_tag_duplicate(hass: HomeAssistant, nfc_tag_id: str, exclude_task_
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# To-do mirror targets (D#183)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mirror_todo_error(hass: HomeAssistant, entity_ids: list[str]) -> str | None:
|
||||
"""Refuse Maintenance Supporter's OWN to-do lists as mirror targets.
|
||||
|
||||
They only support updating rows (no create/delete), so ``todo.add_item``
|
||||
would fail on every refresh — and a task mirrored into the list that
|
||||
already shows it is circular. Returns the error text, or None when every
|
||||
id is acceptable (unknown / not-yet-loaded lists are fine: the mirror
|
||||
self-heals when they appear).
|
||||
"""
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from ..const import DOMAIN
|
||||
|
||||
registry = er.async_get(hass)
|
||||
for eid in entity_ids:
|
||||
reg_entry = registry.async_get(eid)
|
||||
if reg_entry is not None and reg_entry.platform == DOMAIN:
|
||||
return f"{eid} is a Maintenance Supporter list — a task cannot be mirrored into its own integration's list"
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trigger config validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user