New apps Added
This commit is contained in:
@@ -0,0 +1,519 @@
|
||||
"""WebSocket API for the Maintenance Supporter integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from ..const import (
|
||||
CONF_GROUPS,
|
||||
CONF_OBJECT,
|
||||
CONF_TASKS,
|
||||
DEFAULT_WARNING_DAYS,
|
||||
DOMAIN,
|
||||
GLOBAL_UNIQUE_ID,
|
||||
)
|
||||
from ..helpers.aggregate import get_object_entries, get_runtime_data
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helpers (used by handler submodules)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Re-exported under historical underscore names for handler submodules
|
||||
# (analysis.py, vacation.py) that import them from this package. The actual
|
||||
# logic lives in helpers.aggregate (single source for cross-entry aggregation).
|
||||
_get_object_entries = get_object_entries
|
||||
_get_runtime_data = get_runtime_data
|
||||
|
||||
|
||||
def _get_merged_tasks(entry: ConfigEntry) -> dict[str, Any]:
|
||||
"""Return merged task data (static ConfigEntry + dynamic Store) for an entry."""
|
||||
tasks_data = entry.data.get(CONF_TASKS, {})
|
||||
rd = getattr(entry, "runtime_data", None)
|
||||
store = getattr(rd, "store", None) if rd else None
|
||||
if store is not None:
|
||||
return store.merge_all_tasks(tasks_data)
|
||||
return tasks_data
|
||||
|
||||
|
||||
def _build_task_summary(
|
||||
hass: HomeAssistant,
|
||||
task_id: str,
|
||||
task_data: dict[str, Any],
|
||||
coordinator_task: dict[str, Any] | None,
|
||||
object_slug: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a task summary dict for WS responses.
|
||||
|
||||
object_slug (when provided) lets the response include the auto-derived
|
||||
sensor + binary_sensor entity_ids for this task, so frontend cards can
|
||||
use HA's native entity_ids: filter pattern without re-implementing the
|
||||
slugify logic.
|
||||
"""
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from ..entity.triggers import normalize_entity_ids
|
||||
from ..helpers.schedule import Schedule, read_legacy_fields
|
||||
|
||||
ct = coordinator_task or {}
|
||||
|
||||
# Recurrence is echoed in the flat shape the task-dialog expects (derived
|
||||
# from whichever storage shape this task uses — see read_legacy_fields /
|
||||
# issue #58) AND as the nested `schedule` object, which is the only way to
|
||||
# express the calendar kinds (weekdays / nth_weekday / day_of_month).
|
||||
sched = read_legacy_fields(task_data)
|
||||
schedule_obj = Schedule.parse(task_data).to_dict()
|
||||
|
||||
# Enrich trigger config with entity friendly name and state info
|
||||
trigger_config = task_data.get("trigger_config")
|
||||
trigger_entity_info: dict[str, Any] | None = None
|
||||
trigger_entity_infos: list[dict[str, Any]] | None = None
|
||||
|
||||
if trigger_config:
|
||||
entity_ids = normalize_entity_ids(trigger_config)
|
||||
|
||||
# Build info for all entities
|
||||
infos: list[dict[str, Any]] = []
|
||||
for eid in entity_ids:
|
||||
state_obj = hass.states.get(eid)
|
||||
if state_obj is not None:
|
||||
infos.append(
|
||||
{
|
||||
"entity_id": eid,
|
||||
"friendly_name": state_obj.attributes.get("friendly_name", eid),
|
||||
"unit_of_measurement": state_obj.attributes.get("unit_of_measurement"),
|
||||
"min": state_obj.attributes.get("min"),
|
||||
"max": state_obj.attributes.get("max"),
|
||||
"step": state_obj.attributes.get("step"),
|
||||
}
|
||||
)
|
||||
|
||||
# Backwards compat: trigger_entity_info is the first entity
|
||||
if infos:
|
||||
trigger_entity_info = infos[0]
|
||||
# Multi-entity: include all
|
||||
if len(infos) > 1:
|
||||
trigger_entity_infos = infos
|
||||
|
||||
return {
|
||||
"id": task_id,
|
||||
"name": task_data.get("name", ""),
|
||||
"type": task_data.get("type", "custom"),
|
||||
"enabled": task_data.get("enabled", True),
|
||||
"schedule_type": sched["schedule_type"],
|
||||
"interval_days": sched["interval_days"],
|
||||
# interval_unit + due_date are persisted by ws_create_task/ws_update_task
|
||||
# and the config/options flows. They MUST be echoed here (same #50 trap as
|
||||
# on_complete_action below): without interval_unit the task-dialog hydrates
|
||||
# `task.interval_unit || "days"` = "days", so the next save silently resets
|
||||
# a months/years task back to days and corrupts next_due. (issue #58)
|
||||
"interval_unit": sched["interval_unit"],
|
||||
"due_date": sched["due_date"],
|
||||
"interval_anchor": sched["interval_anchor"],
|
||||
# Nested recurrence object — the frontend reads this for the calendar
|
||||
# kinds; the flat fields above remain for interval/one_time back-compat.
|
||||
"schedule": schedule_obj,
|
||||
"last_planned_due": task_data.get("last_planned_due"),
|
||||
"schedule_time": task_data.get("schedule_time"),
|
||||
"warning_days": task_data.get("warning_days", DEFAULT_WARNING_DAYS),
|
||||
"last_performed": task_data.get("last_performed"),
|
||||
"notes": task_data.get("notes"),
|
||||
"documentation_url": task_data.get("documentation_url"),
|
||||
"custom_icon": task_data.get("custom_icon"),
|
||||
"nfc_tag_id": task_data.get("nfc_tag_id"),
|
||||
# v2.20 (#83): unit for `reading`-type tasks; values live in history.
|
||||
"reading_unit": task_data.get("reading_unit"),
|
||||
"priority": task_data.get("priority", "normal"),
|
||||
# v2.10.0 archive: archived_at is the persisted timestamp (None = active);
|
||||
# `archived` is the convenience bool the frontend filters on; reason is
|
||||
# manual | auto | object. All read from the persisted (static) task dict.
|
||||
"archived": task_data.get("archived_at") is not None,
|
||||
"archived_at": task_data.get("archived_at"),
|
||||
"archived_reason": task_data.get("archived_reason"),
|
||||
"responsible_user_id": task_data.get("responsible_user_id"),
|
||||
"assignee_pool": task_data.get("assignee_pool", []),
|
||||
"rotation_strategy": task_data.get("rotation_strategy"),
|
||||
"earliest_completion_days": task_data.get("earliest_completion_days"),
|
||||
"entity_slug": task_data.get("entity_slug"),
|
||||
# Auto-derived entity_ids for this task's sensor + binary_sensor.
|
||||
# Lookup via entity registry by unique_id so we get the actual
|
||||
# registered entity_id (which can differ from the unique_id when the
|
||||
# user has renamed it). None when registry lookup fails (rare).
|
||||
"sensor_entity_id": (
|
||||
er.async_get(hass).async_get_entity_id(
|
||||
"sensor",
|
||||
"maintenance_supporter",
|
||||
f"maintenance_supporter_{object_slug}_{task_id}",
|
||||
)
|
||||
if object_slug
|
||||
else None
|
||||
),
|
||||
"binary_sensor_entity_id": (
|
||||
er.async_get(hass).async_get_entity_id(
|
||||
"binary_sensor",
|
||||
"maintenance_supporter",
|
||||
f"maintenance_supporter_{object_slug}_{task_id}_overdue",
|
||||
)
|
||||
if object_slug
|
||||
else None
|
||||
),
|
||||
"trigger_config": trigger_config,
|
||||
"trigger_entity_info": trigger_entity_info,
|
||||
"trigger_entity_infos": trigger_entity_infos,
|
||||
"checklist": task_data.get("checklist", []),
|
||||
"labels": task_data.get("labels", []),
|
||||
"history": task_data.get("history", []),
|
||||
# v1.3.0 — both fields are persisted by ws_create_task / ws_update_task
|
||||
# and consumed by helpers/action_listener.py on EVENT_TASK_COMPLETED, but
|
||||
# were missing from this response builder until issue #50. Without them
|
||||
# the task-dialog hydrate path (`task.on_complete_action`) sees undefined
|
||||
# on every reload, the user thinks "save didn't work", and the next save
|
||||
# of any other field wipes the persisted action because the dialog's
|
||||
# local state for the action fields is empty (writes back null).
|
||||
"on_complete_action": task_data.get("on_complete_action"),
|
||||
"quick_complete_defaults": task_data.get("quick_complete_defaults"),
|
||||
# Computed fields from coordinator
|
||||
"status": ct.get("_status", "ok"),
|
||||
"is_done": ct.get("_is_done", False),
|
||||
"days_until_due": ct.get("_days_until_due"),
|
||||
"next_due": ct.get("_next_due"),
|
||||
"trigger_active": ct.get("_trigger_active", False),
|
||||
"trigger_current_value": ct.get("_trigger_current_value"),
|
||||
"trigger_entity_state": ct.get("_trigger_entity_state", "available"),
|
||||
"trigger_current_delta": ct.get("_trigger_current_delta"),
|
||||
"trigger_baseline_value": ct.get("_trigger_baseline_value"),
|
||||
"times_performed": ct.get("_times_performed", 0),
|
||||
"total_cost": ct.get("_total_cost", 0.0),
|
||||
"average_duration": ct.get("_average_duration"),
|
||||
# Adaptive scheduling
|
||||
"adaptive_config": task_data.get("adaptive_config"),
|
||||
"suggested_interval": ct.get("_suggested_interval"),
|
||||
"interval_confidence": ct.get("_interval_confidence"),
|
||||
"interval_analysis": ct.get("_interval_analysis"),
|
||||
# Seasonal scheduling (top-level for easy frontend access)
|
||||
"seasonal_factor": (ct.get("_interval_analysis") or {}).get("seasonal_factor"),
|
||||
"seasonal_factors": (ct.get("_interval_analysis") or {}).get("seasonal_factors"),
|
||||
# Sensor-driven predictions (Phase 3)
|
||||
"degradation_rate": ct.get("_degradation_rate"),
|
||||
"degradation_trend": ct.get("_degradation_trend"),
|
||||
"degradation_r_squared": ct.get("_degradation_r_squared"),
|
||||
"days_until_threshold": ct.get("_days_until_threshold"),
|
||||
"threshold_prediction_date": ct.get("_threshold_prediction_date"),
|
||||
"threshold_prediction_confidence": ct.get("_threshold_prediction_confidence"),
|
||||
"environmental_factor": ct.get("_environmental_factor"),
|
||||
"environmental_entity": ct.get("_environmental_entity"),
|
||||
"environmental_correlation": ct.get("_environmental_correlation"),
|
||||
"sensor_prediction_urgency": ct.get("_sensor_prediction_urgency", False),
|
||||
}
|
||||
|
||||
|
||||
def _build_object_response(hass: HomeAssistant, entry: ConfigEntry, coordinator_data: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Build a full object response dict."""
|
||||
from ..const import slugify_object_name
|
||||
|
||||
obj_data = entry.data.get(CONF_OBJECT, {})
|
||||
tasks_data = _get_merged_tasks(entry)
|
||||
ct_tasks = (coordinator_data or {}).get(CONF_TASKS, {})
|
||||
object_slug = slugify_object_name(obj_data.get("name", "unknown"))
|
||||
|
||||
tasks = [_build_task_summary(hass, tid, tdata, ct_tasks.get(tid), object_slug) for tid, tdata in tasks_data.items()]
|
||||
|
||||
# (roadmap P2) attached-document count for the objects-table paperclip badge.
|
||||
from .. import DOCUMENT_STORE_KEY
|
||||
|
||||
doc_store = hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY)
|
||||
object_id = obj_data.get("id", "")
|
||||
document_count = len(doc_store.for_object(object_id)) if doc_store is not None and object_id else 0
|
||||
|
||||
return {
|
||||
"entry_id": entry.entry_id,
|
||||
"object": {
|
||||
"id": obj_data.get("id", ""),
|
||||
"name": obj_data.get("name", ""),
|
||||
"area_id": obj_data.get("area_id"),
|
||||
"manufacturer": obj_data.get("manufacturer"),
|
||||
"model": obj_data.get("model"),
|
||||
"serial_number": obj_data.get("serial_number"),
|
||||
"installation_date": obj_data.get("installation_date"),
|
||||
# (#67): warranty expiry — exposed for the asset table + detail view
|
||||
"warranty_expiry": obj_data.get("warranty_expiry"),
|
||||
"ha_device_id": obj_data.get("ha_device_id"),
|
||||
"parent_entry_id": obj_data.get("parent_entry_id"),
|
||||
# v1.4.0 (#43): expose to the frontend so the manual link
|
||||
# renders in the object detail header AND, since v1.4.1, on
|
||||
# every task detail page belonging to this object.
|
||||
"documentation_url": obj_data.get("documentation_url"),
|
||||
# v1.4.10 (#46): free-form notes shown below the meta block.
|
||||
"notes": obj_data.get("notes"),
|
||||
# v2.10.0 archive: object-level archived state. `archived` bool +
|
||||
# the raw timestamp so the panel can hide it by default and show
|
||||
# "archived on …" in the Archived section.
|
||||
"archived": obj_data.get("archived_at") is not None,
|
||||
"archived_at": obj_data.get("archived_at"),
|
||||
# v2.20 (N3) seasonal pause: `paused` bool + the optional
|
||||
# auto-resume date for the "Paused until …" badge.
|
||||
"paused": obj_data.get("paused_at") is not None,
|
||||
"paused_at": obj_data.get("paused_at"),
|
||||
"paused_until": obj_data.get("paused_until"),
|
||||
# v2.20 (N1) replace-flow lineage, both directions.
|
||||
"predecessor_entry_id": obj_data.get("predecessor_entry_id"),
|
||||
"replaced_by_entry_id": obj_data.get("replaced_by_entry_id"),
|
||||
# (roadmap P2) count of attached documents (files + web-links) for
|
||||
# the objects-table paperclip badge; computed, not persisted.
|
||||
"document_count": document_count,
|
||||
},
|
||||
"tasks": tasks,
|
||||
}
|
||||
|
||||
|
||||
def _get_global_entry(hass: HomeAssistant) -> ConfigEntry | None:
|
||||
"""Get the global config entry."""
|
||||
for entry in hass.config_entries.async_entries(DOMAIN):
|
||||
if entry.unique_id == GLOBAL_UNIQUE_ID:
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def _load_object_entry(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
*,
|
||||
not_found_message: str = "Object not found",
|
||||
) -> ConfigEntry | None:
|
||||
"""Resolve ``msg["entry_id"]`` to an OBJECT entry (not the global one).
|
||||
|
||||
Sends a ``not_found`` WS error and returns None when the entry is
|
||||
missing, belongs to another domain, or is the global entry. Callers
|
||||
must early-return on None — same shape as the manual guard pattern
|
||||
that was duplicated across 12 WS handlers before this helper.
|
||||
|
||||
The helper centralises the three-part guard introduced ad-hoc in
|
||||
every CRUD handler (entry None / wrong domain / global) so future
|
||||
additions can't forget any of the three checks.
|
||||
"""
|
||||
entry = hass.config_entries.async_get_entry(msg["entry_id"])
|
||||
if entry is None or entry.domain != DOMAIN or entry.unique_id == GLOBAL_UNIQUE_ID:
|
||||
connection.send_error(msg["id"], "not_found", not_found_message)
|
||||
return None
|
||||
return entry
|
||||
|
||||
|
||||
def object_id_for_entry(entry: ConfigEntry) -> str:
|
||||
"""Return the stable document key for an object entry.
|
||||
|
||||
Documents are keyed by the object's own ``id`` (a uuid hex persisted in the
|
||||
entry data) rather than the ``entry_id`` so the association survives
|
||||
export/import — an imported object keeps its id but gets a fresh entry_id.
|
||||
Falls back to ``entry_id`` only for legacy entries created before object ids
|
||||
existed.
|
||||
"""
|
||||
oid = entry.data.get(CONF_OBJECT, {}).get("id")
|
||||
return oid if isinstance(oid, str) and oid else entry.entry_id
|
||||
|
||||
|
||||
def cleanup_group_refs(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
entry_id: str | None = None,
|
||||
task_id: str | None = None,
|
||||
) -> None:
|
||||
"""Remove deleted task/object references from all groups.
|
||||
|
||||
Pass entry_id to remove all refs for that object.
|
||||
Pass task_id to remove refs for a specific task.
|
||||
"""
|
||||
global_entry = _get_global_entry(hass)
|
||||
if global_entry is None:
|
||||
return
|
||||
|
||||
options = dict(global_entry.options or global_entry.data)
|
||||
groups = options.get(CONF_GROUPS)
|
||||
if not groups:
|
||||
return
|
||||
|
||||
groups = dict(groups)
|
||||
changed = False
|
||||
for gid, group in groups.items():
|
||||
old_refs = group.get("task_refs", [])
|
||||
new_refs = [
|
||||
ref
|
||||
for ref in old_refs
|
||||
if not (
|
||||
(entry_id is not None and ref.get("entry_id") == entry_id)
|
||||
or (task_id is not None and ref.get("task_id") == task_id)
|
||||
)
|
||||
]
|
||||
if len(new_refs) != len(old_refs):
|
||||
groups[gid] = {**group, "task_refs": new_refs}
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
options[CONF_GROUPS] = groups
|
||||
hass.config_entries.async_update_entry(global_entry, options=options)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Re-exports for backward compatibility (used by tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from .tasks import ( # noqa: F401
|
||||
_validate_compound_trigger,
|
||||
_validate_trigger_config,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_commands(hass: HomeAssistant) -> None:
|
||||
"""Register all WebSocket commands."""
|
||||
from .analysis import (
|
||||
ws_analyze_interval,
|
||||
ws_apply_suggestion,
|
||||
ws_seasonal_overrides,
|
||||
ws_set_environmental_entity,
|
||||
)
|
||||
from .dashboard import (
|
||||
ws_get_budget_status,
|
||||
ws_get_settings,
|
||||
ws_get_statistics,
|
||||
ws_subscribe,
|
||||
ws_test_notification,
|
||||
ws_update_global_settings,
|
||||
)
|
||||
from .documents import (
|
||||
ws_documents_add_link,
|
||||
ws_documents_delete,
|
||||
ws_documents_list,
|
||||
ws_documents_search,
|
||||
ws_documents_storage,
|
||||
ws_documents_update,
|
||||
)
|
||||
from .groups import (
|
||||
ws_create_group,
|
||||
ws_delete_group,
|
||||
ws_get_groups,
|
||||
ws_update_group,
|
||||
)
|
||||
from .io import (
|
||||
ws_batch_generate_qr,
|
||||
ws_export_csv,
|
||||
ws_export_data,
|
||||
ws_export_objects_csv,
|
||||
ws_generate_qr,
|
||||
ws_get_templates,
|
||||
ws_import_csv,
|
||||
ws_import_json,
|
||||
)
|
||||
from .objects import (
|
||||
ws_archive_object,
|
||||
ws_create_from_template,
|
||||
ws_create_object,
|
||||
ws_delete_object,
|
||||
ws_duplicate_object,
|
||||
ws_entity_attributes,
|
||||
ws_get_object,
|
||||
ws_get_objects,
|
||||
ws_pause_object,
|
||||
ws_replace_object,
|
||||
ws_resume_object,
|
||||
ws_unarchive_object,
|
||||
ws_update_object,
|
||||
)
|
||||
from .tags import ws_list_tags
|
||||
from .tasks import (
|
||||
ws_archive_task,
|
||||
ws_complete_task,
|
||||
ws_create_task,
|
||||
ws_delete_task,
|
||||
ws_duplicate_task,
|
||||
ws_list_tasks,
|
||||
ws_quick_complete_task,
|
||||
ws_reset_task,
|
||||
ws_skip_task,
|
||||
ws_snooze_task,
|
||||
ws_unarchive_task,
|
||||
ws_update_history_entry,
|
||||
ws_update_task,
|
||||
)
|
||||
from .users import ws_assign_user, ws_list_users, ws_tasks_by_user
|
||||
from .vacation import (
|
||||
ws_vacation_end_now,
|
||||
ws_vacation_preview,
|
||||
ws_vacation_state,
|
||||
ws_vacation_update,
|
||||
)
|
||||
|
||||
websocket_api.async_register_command(hass, ws_get_objects)
|
||||
websocket_api.async_register_command(hass, ws_get_object)
|
||||
websocket_api.async_register_command(hass, ws_get_statistics)
|
||||
websocket_api.async_register_command(hass, ws_subscribe)
|
||||
websocket_api.async_register_command(hass, ws_create_object)
|
||||
websocket_api.async_register_command(hass, ws_update_object)
|
||||
websocket_api.async_register_command(hass, ws_delete_object)
|
||||
websocket_api.async_register_command(hass, ws_duplicate_object)
|
||||
websocket_api.async_register_command(hass, ws_create_from_template)
|
||||
websocket_api.async_register_command(hass, ws_archive_object)
|
||||
websocket_api.async_register_command(hass, ws_unarchive_object)
|
||||
websocket_api.async_register_command(hass, ws_pause_object)
|
||||
websocket_api.async_register_command(hass, ws_resume_object)
|
||||
websocket_api.async_register_command(hass, ws_replace_object)
|
||||
websocket_api.async_register_command(hass, ws_create_task)
|
||||
websocket_api.async_register_command(hass, ws_update_task)
|
||||
websocket_api.async_register_command(hass, ws_delete_task)
|
||||
websocket_api.async_register_command(hass, ws_duplicate_task)
|
||||
websocket_api.async_register_command(hass, ws_archive_task)
|
||||
websocket_api.async_register_command(hass, ws_unarchive_task)
|
||||
websocket_api.async_register_command(hass, ws_list_tasks)
|
||||
websocket_api.async_register_command(hass, ws_complete_task)
|
||||
websocket_api.async_register_command(hass, ws_quick_complete_task)
|
||||
websocket_api.async_register_command(hass, ws_skip_task)
|
||||
websocket_api.async_register_command(hass, ws_reset_task)
|
||||
websocket_api.async_register_command(hass, ws_snooze_task)
|
||||
websocket_api.async_register_command(hass, ws_update_history_entry)
|
||||
websocket_api.async_register_command(hass, ws_get_templates)
|
||||
websocket_api.async_register_command(hass, ws_export_data)
|
||||
websocket_api.async_register_command(hass, ws_get_budget_status)
|
||||
websocket_api.async_register_command(hass, ws_export_csv)
|
||||
websocket_api.async_register_command(hass, ws_export_objects_csv)
|
||||
websocket_api.async_register_command(hass, ws_import_csv)
|
||||
websocket_api.async_register_command(hass, ws_import_json)
|
||||
websocket_api.async_register_command(hass, ws_get_groups)
|
||||
websocket_api.async_register_command(hass, ws_create_group)
|
||||
websocket_api.async_register_command(hass, ws_update_group)
|
||||
websocket_api.async_register_command(hass, ws_delete_group)
|
||||
websocket_api.async_register_command(hass, ws_analyze_interval)
|
||||
websocket_api.async_register_command(hass, ws_apply_suggestion)
|
||||
websocket_api.async_register_command(hass, ws_seasonal_overrides)
|
||||
websocket_api.async_register_command(hass, ws_set_environmental_entity)
|
||||
websocket_api.async_register_command(hass, ws_generate_qr)
|
||||
websocket_api.async_register_command(hass, ws_batch_generate_qr)
|
||||
websocket_api.async_register_command(hass, ws_get_settings)
|
||||
websocket_api.async_register_command(hass, ws_update_global_settings)
|
||||
websocket_api.async_register_command(hass, ws_test_notification)
|
||||
websocket_api.async_register_command(hass, ws_list_users)
|
||||
websocket_api.async_register_command(hass, ws_assign_user)
|
||||
websocket_api.async_register_command(hass, ws_tasks_by_user)
|
||||
websocket_api.async_register_command(hass, ws_entity_attributes)
|
||||
websocket_api.async_register_command(hass, ws_list_tags)
|
||||
websocket_api.async_register_command(hass, ws_vacation_state)
|
||||
websocket_api.async_register_command(hass, ws_vacation_update)
|
||||
websocket_api.async_register_command(hass, ws_vacation_preview)
|
||||
websocket_api.async_register_command(hass, ws_vacation_end_now)
|
||||
websocket_api.async_register_command(hass, ws_documents_list)
|
||||
websocket_api.async_register_command(hass, ws_documents_storage)
|
||||
websocket_api.async_register_command(hass, ws_documents_add_link)
|
||||
websocket_api.async_register_command(hass, ws_documents_update)
|
||||
websocket_api.async_register_command(hass, ws_documents_delete)
|
||||
websocket_api.async_register_command(hass, ws_documents_search)
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,271 @@
|
||||
"""WebSocket handlers for adaptive scheduling and analysis."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..const import (
|
||||
CONF_TASKS,
|
||||
DOMAIN,
|
||||
MAX_ENTITY_ID_LENGTH,
|
||||
MAX_ID_LENGTH,
|
||||
MAX_META_LENGTH,
|
||||
)
|
||||
from ..helpers.permissions import require_write
|
||||
from . import _get_merged_tasks, _get_runtime_data, _load_object_entry
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): f"{DOMAIN}/task/analyze_interval",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_analyze_interval(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return full interval analysis for a task (on-demand)."""
|
||||
from ..helpers.interval_analyzer import IntervalAnalyzer
|
||||
|
||||
entry = _load_object_entry(hass, connection, msg)
|
||||
if entry is None:
|
||||
return
|
||||
|
||||
tasks_data = _get_merged_tasks(entry)
|
||||
task_id = msg["task_id"]
|
||||
if task_id not in tasks_data:
|
||||
connection.send_error(msg["id"], "not_found", "Task not found")
|
||||
return
|
||||
|
||||
task_data = tasks_data[task_id]
|
||||
adaptive_config = dict(task_data.get("adaptive_config", {}))
|
||||
|
||||
# Inject hemisphere and current month for seasonal awareness
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
adaptive_config["hemisphere"] = "south" if (hass.config.latitude or 0) < 0 else "north"
|
||||
adaptive_config["_current_month"] = dt_util.now().month
|
||||
|
||||
analyzer = IntervalAnalyzer()
|
||||
analysis = analyzer.analyze(task_data, adaptive_config)
|
||||
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
"current_interval": analysis.current_interval,
|
||||
"average_actual_interval": analysis.average_actual_interval,
|
||||
"interval_std_dev": analysis.interval_std_dev,
|
||||
"ewa_prediction": analysis.ewa_prediction,
|
||||
"weibull_prediction": analysis.weibull_prediction,
|
||||
"weibull_beta": analysis.weibull_beta,
|
||||
"weibull_eta": analysis.weibull_eta,
|
||||
"recommended_interval": analysis.recommended_interval,
|
||||
"confidence": analysis.confidence,
|
||||
"feedback_count": analysis.feedback_count,
|
||||
"data_points": analysis.data_points,
|
||||
"recommendation_reason": analysis.recommendation_reason,
|
||||
"seasonal_factor": analysis.seasonal_factor,
|
||||
"seasonal_factors": analysis.seasonal_factors,
|
||||
"seasonal_reason": analysis.seasonal_adjustment_reason,
|
||||
"weibull_r_squared": analysis.weibull_r_squared,
|
||||
"confidence_interval_low": analysis.confidence_interval_low,
|
||||
"confidence_interval_high": analysis.confidence_interval_high,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): f"{DOMAIN}/task/apply_suggestion",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Required("interval"): vol.All(int, vol.Range(min=1, max=3650)),
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_apply_suggestion(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Apply a suggested interval to a task."""
|
||||
rd = _get_runtime_data(hass, msg["entry_id"])
|
||||
if rd is None or rd.coordinator is None:
|
||||
connection.send_error(msg["id"], "not_found", "Coordinator not found")
|
||||
return
|
||||
|
||||
# Reject an unknown task_id instead of silently no-opping and returning
|
||||
# success (the apply call is a no-op for a missing task).
|
||||
if msg["task_id"] not in rd.coordinator.entry.data.get(CONF_TASKS, {}):
|
||||
connection.send_error(msg["id"], "not_found", "Task not found")
|
||||
return
|
||||
|
||||
await rd.coordinator.async_apply_suggested_interval(
|
||||
task_id=msg["task_id"],
|
||||
interval=msg["interval"],
|
||||
)
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): f"{DOMAIN}/task/seasonal_overrides",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Required("overrides"): dict,
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_seasonal_overrides(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Set manual seasonal overrides for a task.
|
||||
|
||||
Overrides is a dict of {month_num: factor}, e.g. {7: 0.5, 1: 2.0}.
|
||||
Keys must be 1-12, values must be 0.1-5.0.
|
||||
Pass empty dict {} to clear all overrides.
|
||||
"""
|
||||
entry = _load_object_entry(hass, connection, msg)
|
||||
if entry is None:
|
||||
return
|
||||
|
||||
task_id = msg["task_id"]
|
||||
tasks_data = _get_merged_tasks(entry)
|
||||
if task_id not in tasks_data:
|
||||
connection.send_error(msg["id"], "not_found", "Task not found")
|
||||
return
|
||||
|
||||
# Validate overrides
|
||||
overrides = msg["overrides"]
|
||||
validated: dict[int, float] = {}
|
||||
for key, value in overrides.items():
|
||||
try:
|
||||
month = int(key)
|
||||
factor = float(value)
|
||||
except (ValueError, TypeError):
|
||||
connection.send_error(msg["id"], "invalid_input", f"Invalid override: key={key}, value={value}")
|
||||
return
|
||||
if month < 1 or month > 12:
|
||||
connection.send_error(msg["id"], "invalid_input", f"Month must be 1-12, got {month}")
|
||||
return
|
||||
if factor < 0.1 or factor > 5.0:
|
||||
connection.send_error(msg["id"], "invalid_input", f"Factor must be 0.1-5.0, got {factor}")
|
||||
return
|
||||
validated[month] = round(factor, 2)
|
||||
|
||||
# Persist overrides in adaptive_config
|
||||
adaptive_config = dict(tasks_data[task_id].get("adaptive_config", {}))
|
||||
if validated:
|
||||
adaptive_config["seasonal_overrides"] = validated
|
||||
else:
|
||||
adaptive_config.pop("seasonal_overrides", None)
|
||||
|
||||
rd = _get_runtime_data(hass, entry.entry_id)
|
||||
store = getattr(rd, "store", None) if rd else None
|
||||
if store is not None:
|
||||
store.set_adaptive_config(task_id, adaptive_config)
|
||||
store.async_delay_save()
|
||||
else:
|
||||
# Legacy: write to ConfigEntry.data
|
||||
static_tasks = dict(entry.data.get(CONF_TASKS, {}))
|
||||
task = dict(static_tasks[task_id])
|
||||
task["adaptive_config"] = adaptive_config
|
||||
static_tasks[task_id] = task
|
||||
new_data = dict(entry.data)
|
||||
new_data[CONF_TASKS] = static_tasks
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
|
||||
# Refresh coordinator
|
||||
if rd and rd.coordinator:
|
||||
await rd.coordinator.async_request_refresh()
|
||||
|
||||
connection.send_result(msg["id"], {"success": True, "overrides": validated})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): f"{DOMAIN}/task/set_environmental_entity",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Optional("environmental_entity"): vol.Any(vol.All(str, vol.Length(max=MAX_ENTITY_ID_LENGTH)), None),
|
||||
vol.Optional("environmental_attribute"): vol.Any(vol.All(str, vol.Length(max=MAX_META_LENGTH)), None),
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_set_environmental_entity(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Set or clear the environmental entity for sensor-driven predictions.
|
||||
|
||||
When set, the environmental sensor (e.g. outdoor temperature) is
|
||||
correlated with maintenance intervals to produce an adjustment factor.
|
||||
Pass environmental_entity=null to clear the binding.
|
||||
"""
|
||||
entry = _load_object_entry(hass, connection, msg)
|
||||
if entry is None:
|
||||
return
|
||||
|
||||
task_id = msg["task_id"]
|
||||
tasks_data = _get_merged_tasks(entry)
|
||||
if task_id not in tasks_data:
|
||||
connection.send_error(msg["id"], "not_found", "Task not found")
|
||||
return
|
||||
|
||||
adaptive_config = dict(tasks_data[task_id].get("adaptive_config", {}))
|
||||
|
||||
env_entity = msg.get("environmental_entity")
|
||||
env_attribute = msg.get("environmental_attribute")
|
||||
|
||||
if env_entity:
|
||||
adaptive_config["environmental_entity"] = env_entity
|
||||
if env_attribute:
|
||||
adaptive_config["environmental_attribute"] = env_attribute
|
||||
else:
|
||||
adaptive_config.pop("environmental_attribute", None)
|
||||
else:
|
||||
# Clear environmental binding
|
||||
adaptive_config.pop("environmental_entity", None)
|
||||
adaptive_config.pop("environmental_attribute", None)
|
||||
|
||||
rd = _get_runtime_data(hass, entry.entry_id)
|
||||
store = getattr(rd, "store", None) if rd else None
|
||||
if store is not None:
|
||||
store.set_adaptive_config(task_id, adaptive_config)
|
||||
store.async_delay_save()
|
||||
else:
|
||||
# Legacy: write to ConfigEntry.data
|
||||
static_tasks = dict(entry.data.get(CONF_TASKS, {}))
|
||||
task = dict(static_tasks[task_id])
|
||||
task["adaptive_config"] = adaptive_config
|
||||
static_tasks[task_id] = task
|
||||
new_data = dict(entry.data)
|
||||
new_data[CONF_TASKS] = static_tasks
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
|
||||
# Refresh coordinator
|
||||
if rd and rd.coordinator:
|
||||
await rd.coordinator.async_request_refresh()
|
||||
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
"success": True,
|
||||
"environmental_entity": env_entity,
|
||||
"environmental_attribute": env_attribute,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,593 @@
|
||||
"""WebSocket handlers for subscribe, statistics, settings, and budget."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
from ..const import (
|
||||
BUDGET_CURRENCIES,
|
||||
CONF_ACTION_COMPLETE_ENABLED,
|
||||
CONF_ACTION_SKIP_ENABLED,
|
||||
CONF_ACTION_SNOOZE_ENABLED,
|
||||
CONF_ADMIN_PANEL_USER_IDS,
|
||||
CONF_ADVANCED_ADAPTIVE,
|
||||
CONF_ADVANCED_BUDGET,
|
||||
CONF_ADVANCED_CHECKLISTS,
|
||||
CONF_ADVANCED_COMPLETION_ACTIONS,
|
||||
CONF_ADVANCED_ENVIRONMENTAL,
|
||||
CONF_ADVANCED_GROUPS,
|
||||
CONF_ADVANCED_PREDICTIONS,
|
||||
CONF_ADVANCED_SCHEDULE_TIME,
|
||||
CONF_ADVANCED_SEASONAL,
|
||||
CONF_ARCHIVE_ONEOFF_DAYS,
|
||||
CONF_BUDGET_ALERT_THRESHOLD,
|
||||
CONF_BUDGET_ALERTS_ENABLED,
|
||||
CONF_BUDGET_CURRENCY,
|
||||
CONF_BUDGET_MONTHLY,
|
||||
CONF_BUDGET_YEARLY,
|
||||
CONF_DEFAULT_WARNING_DAYS,
|
||||
CONF_DELETE_ARCHIVED_ONEOFF_DAYS,
|
||||
CONF_DISABLED_TEMPLATE_IDS,
|
||||
CONF_MAX_NOTIFICATIONS_PER_DAY,
|
||||
CONF_NOTIFICATION_BUNDLE_THRESHOLD,
|
||||
CONF_NOTIFICATION_BUNDLING_ENABLED,
|
||||
CONF_NOTIFICATION_TITLE_STYLE,
|
||||
CONF_NOTIFICATIONS_ENABLED,
|
||||
CONF_NOTIFY_DUE_SOON_ENABLED,
|
||||
CONF_NOTIFY_DUE_SOON_INTERVAL,
|
||||
CONF_NOTIFY_OVERDUE_ENABLED,
|
||||
CONF_NOTIFY_OVERDUE_INTERVAL,
|
||||
CONF_NOTIFY_SERVICE,
|
||||
CONF_NOTIFY_TRIGGERED_ENABLED,
|
||||
CONF_NOTIFY_TRIGGERED_INTERVAL,
|
||||
CONF_OBJECTS_TABLE_COLUMNS,
|
||||
CONF_OPERATOR_WRITE_ENABLED,
|
||||
CONF_PANEL_ENABLED,
|
||||
CONF_PANEL_TITLE,
|
||||
CONF_QUIET_HOURS_ENABLED,
|
||||
CONF_QUIET_HOURS_END,
|
||||
CONF_QUIET_HOURS_START,
|
||||
CONF_REMINDER_LEAD_DAYS,
|
||||
CONF_SNOOZE_DURATION_HOURS,
|
||||
CONF_WARRANTY_REMINDER_DAYS,
|
||||
CONF_WARRANTY_REMINDER_ENABLED,
|
||||
CONF_WEEKLY_DIGEST_ENABLED,
|
||||
DEFAULT_ARCHIVE_ONEOFF_DAYS,
|
||||
DEFAULT_BUDGET_CURRENCY,
|
||||
DEFAULT_DELETE_ARCHIVED_ONEOFF_DAYS,
|
||||
DEFAULT_OBJECTS_TABLE_COLUMNS,
|
||||
DEFAULT_PANEL_ENABLED,
|
||||
DEFAULT_SNOOZE_DURATION_HOURS,
|
||||
DEFAULT_WARNING_DAYS,
|
||||
DEFAULT_WARRANTY_REMINDER_DAYS,
|
||||
DOMAIN,
|
||||
KNOWN_OBJECT_TABLE_COLUMNS,
|
||||
MAX_PANEL_TITLE_LENGTH,
|
||||
MAX_REMINDER_LEADS,
|
||||
SIGNAL_NEW_OBJECT_ENTRY,
|
||||
TIME_HHMMSS_PATTERN,
|
||||
)
|
||||
from ..helpers.aggregate import compute_status_counts
|
||||
from ..helpers.notify_targets import build_notify_targets
|
||||
from ..helpers.settings_registry import (
|
||||
ALLOWED_SETTING_KEYS,
|
||||
FLOAT_RANGES,
|
||||
INT_RANGES,
|
||||
STR_MAX_LENGTHS,
|
||||
)
|
||||
from . import (
|
||||
_build_object_response,
|
||||
_get_global_entry,
|
||||
_get_object_entries,
|
||||
_get_runtime_data,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Keys accepted by global/update + their range/cap tables are derived from the
|
||||
# single settings registry (helpers/settings_registry) so they can't drift from
|
||||
# each other or from the options-flow selectors that share the same specs.
|
||||
_ALLOWED_SETTING_KEYS = ALLOWED_SETTING_KEYS
|
||||
|
||||
|
||||
def _build_full_settings(options: Mapping[str, Any], *, notify_targets: list[str] | None = None) -> dict[str, Any]:
|
||||
"""Build a full settings dict from global entry options.
|
||||
|
||||
``notify_targets`` is the shared pickable-notify-target list (see
|
||||
``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.
|
||||
"""
|
||||
return {
|
||||
"features": {
|
||||
"adaptive": options.get(CONF_ADVANCED_ADAPTIVE, False),
|
||||
"predictions": options.get(CONF_ADVANCED_PREDICTIONS, False),
|
||||
"seasonal": options.get(CONF_ADVANCED_SEASONAL, False),
|
||||
"environmental": options.get(CONF_ADVANCED_ENVIRONMENTAL, False),
|
||||
"budget": options.get(CONF_ADVANCED_BUDGET, False),
|
||||
"groups": options.get(CONF_ADVANCED_GROUPS, False),
|
||||
"checklists": options.get(CONF_ADVANCED_CHECKLISTS, False),
|
||||
"schedule_time": options.get(CONF_ADVANCED_SCHEDULE_TIME, False),
|
||||
"completion_actions": options.get(CONF_ADVANCED_COMPLETION_ACTIONS, False),
|
||||
},
|
||||
# Top-level (not a feature toggle, not a bool): list of HA user IDs
|
||||
# whose UI gets the full admin panel even though they're not HA admins.
|
||||
"admin_panel_user_ids": options.get(CONF_ADMIN_PANEL_USER_IDS, []),
|
||||
# v2.8.4: master switch gating whether the allowlist actually grants
|
||||
# write. Default False → operator allowlist is read-only.
|
||||
"operator_write_enabled": options.get(CONF_OPERATOR_WRITE_ENABLED, False),
|
||||
# (#67): ordered objects-table columns for the panel All-Objects view.
|
||||
"objects_table_columns": options.get(CONF_OBJECTS_TABLE_COLUMNS, DEFAULT_OBJECTS_TABLE_COLUMNS),
|
||||
# v2.21: template-gallery curation (ids hidden from the pickers).
|
||||
"disabled_template_ids": options.get(CONF_DISABLED_TEMPLATE_IDS, []),
|
||||
# v2.10.0: archive automation thresholds (panel Settings → Archive).
|
||||
# oneoff_days: auto-archive a completed one-off after N days (0 = off).
|
||||
# delete_archived_oneoff_days: auto-delete an auto-archived one-off N
|
||||
# days after archiving (0 = never; manual archives are never deleted).
|
||||
"archive": {
|
||||
"oneoff_days": options.get(CONF_ARCHIVE_ONEOFF_DAYS, DEFAULT_ARCHIVE_ONEOFF_DAYS),
|
||||
"delete_archived_oneoff_days": options.get(
|
||||
CONF_DELETE_ARCHIVED_ONEOFF_DAYS,
|
||||
DEFAULT_DELETE_ARCHIVED_ONEOFF_DAYS,
|
||||
),
|
||||
},
|
||||
"general": {
|
||||
"default_warning_days": options.get(CONF_DEFAULT_WARNING_DAYS, DEFAULT_WARNING_DAYS),
|
||||
"notifications_enabled": options.get(CONF_NOTIFICATIONS_ENABLED, False),
|
||||
"notify_service": options.get(CONF_NOTIFY_SERVICE, ""),
|
||||
# Shared pickable-target list so the panel picker can't drift from
|
||||
# the options-flow dropdown (both go through build_notify_targets).
|
||||
"notify_targets": notify_targets or [],
|
||||
"panel_enabled": options.get(CONF_PANEL_ENABLED, DEFAULT_PANEL_ENABLED),
|
||||
"panel_title": options.get(CONF_PANEL_TITLE, ""),
|
||||
},
|
||||
"notifications": {
|
||||
"due_soon_enabled": options.get(CONF_NOTIFY_DUE_SOON_ENABLED, True),
|
||||
"due_soon_interval_hours": options.get(CONF_NOTIFY_DUE_SOON_INTERVAL, 24),
|
||||
"overdue_enabled": options.get(CONF_NOTIFY_OVERDUE_ENABLED, True),
|
||||
"overdue_interval_hours": options.get(CONF_NOTIFY_OVERDUE_INTERVAL, 12),
|
||||
"triggered_enabled": options.get(CONF_NOTIFY_TRIGGERED_ENABLED, True),
|
||||
"triggered_interval_hours": options.get(CONF_NOTIFY_TRIGGERED_INTERVAL, 0),
|
||||
"quiet_hours_enabled": options.get(CONF_QUIET_HOURS_ENABLED, True),
|
||||
"quiet_hours_start": options.get(CONF_QUIET_HOURS_START, "22:00"),
|
||||
"quiet_hours_end": options.get(CONF_QUIET_HOURS_END, "08:00"),
|
||||
"max_per_day": options.get(CONF_MAX_NOTIFICATIONS_PER_DAY, 0),
|
||||
"bundling_enabled": options.get(CONF_NOTIFICATION_BUNDLING_ENABLED, False),
|
||||
"bundle_threshold": options.get(CONF_NOTIFICATION_BUNDLE_THRESHOLD, 2),
|
||||
# v1.4.0 (#44): default keeps backwards-compatible per-status titles
|
||||
"title_style": options.get(CONF_NOTIFICATION_TITLE_STYLE, "default"),
|
||||
# Multiple lead-time reminders (days before due); [] = off.
|
||||
"reminder_lead_days": options.get(CONF_REMINDER_LEAD_DAYS, []),
|
||||
},
|
||||
"actions": {
|
||||
"complete_enabled": options.get(CONF_ACTION_COMPLETE_ENABLED, False),
|
||||
"skip_enabled": options.get(CONF_ACTION_SKIP_ENABLED, False),
|
||||
"snooze_enabled": options.get(CONF_ACTION_SNOOZE_ENABLED, False),
|
||||
"snooze_duration_hours": options.get(CONF_SNOOZE_DURATION_HOURS, DEFAULT_SNOOZE_DURATION_HOURS),
|
||||
"weekly_digest_enabled": options.get(CONF_WEEKLY_DIGEST_ENABLED, False),
|
||||
"warranty_reminder_enabled": options.get(CONF_WARRANTY_REMINDER_ENABLED, False),
|
||||
"warranty_reminder_days": options.get(CONF_WARRANTY_REMINDER_DAYS, DEFAULT_WARRANTY_REMINDER_DAYS),
|
||||
},
|
||||
"budget": {
|
||||
"monthly": options.get(CONF_BUDGET_MONTHLY, 0.0),
|
||||
"yearly": options.get(CONF_BUDGET_YEARLY, 0.0),
|
||||
"alerts_enabled": options.get(CONF_BUDGET_ALERTS_ENABLED, False),
|
||||
"alert_threshold_pct": options.get(CONF_BUDGET_ALERT_THRESHOLD, 80),
|
||||
"currency": options.get(CONF_BUDGET_CURRENCY, DEFAULT_BUDGET_CURRENCY),
|
||||
"currency_symbol": BUDGET_CURRENCIES.get(
|
||||
options.get(CONF_BUDGET_CURRENCY, DEFAULT_BUDGET_CURRENCY),
|
||||
BUDGET_CURRENCIES[DEFAULT_BUDGET_CURRENCY],
|
||||
),
|
||||
},
|
||||
# Vacation mode (v1.2.0). Mirror the active flag so the panel can
|
||||
# decide whether to show the Vacation tab without a separate WS call.
|
||||
"vacation": _vacation_summary(options),
|
||||
}
|
||||
|
||||
|
||||
def _vacation_summary(options: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Embed-friendly slice of vacation state for the /settings response.
|
||||
|
||||
Builds the canonical VacationState from the options mapping and serialises
|
||||
via its single wire serializer, so the /settings embed and /vacation/state
|
||||
can never drift (window/active math lives in one place).
|
||||
"""
|
||||
from ..helpers.vacation import VacationState
|
||||
|
||||
return VacationState.from_options(options).as_wire_dict()
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/settings"})
|
||||
@websocket_api.async_response
|
||||
async def ws_get_settings(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return all global settings."""
|
||||
global_entry = _get_global_entry(hass)
|
||||
if global_entry is None:
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
_build_full_settings({}, notify_targets=build_notify_targets(hass)),
|
||||
)
|
||||
return
|
||||
|
||||
options = global_entry.options or global_entry.data
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
_build_full_settings(
|
||||
options,
|
||||
notify_targets=build_notify_targets(hass, current=options.get(CONF_NOTIFY_SERVICE, "")),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "maintenance_supporter/statistics"})
|
||||
@websocket_api.async_response
|
||||
async def ws_get_statistics(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return aggregated statistics."""
|
||||
# Counts come from the shared aggregator (single source of truth) so the
|
||||
# panel/card chips and the global summary sensors can never diverge.
|
||||
counts = compute_status_counts(hass)
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
"total_objects": counts["total_objects"],
|
||||
"total_tasks": counts["total_tasks"],
|
||||
"overdue": counts["overdue"],
|
||||
"due_soon": counts["due_soon"],
|
||||
"triggered": counts["triggered"],
|
||||
"total_cost": counts["total_cost"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "maintenance_supporter/subscribe"})
|
||||
@websocket_api.async_response
|
||||
async def ws_subscribe(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Subscribe to real-time maintenance updates."""
|
||||
attached_entry_ids: set[str] = set()
|
||||
unsub_callbacks: list[Callable[[], None]] = []
|
||||
|
||||
@callback
|
||||
def _forward_update() -> None:
|
||||
"""Forward coordinator updates to the WebSocket."""
|
||||
entries = _get_object_entries(hass)
|
||||
result = []
|
||||
for entry in entries:
|
||||
rd = _get_runtime_data(hass, entry.entry_id)
|
||||
coord_data = rd.coordinator.data if rd and rd.coordinator else None
|
||||
result.append(_build_object_response(hass, entry, coord_data))
|
||||
connection.send_message(websocket_api.event_message(msg["id"], {"objects": result}))
|
||||
|
||||
def _attach_entry(entry_id: str) -> None:
|
||||
"""Attach a coordinator listener for a specific entry."""
|
||||
if entry_id in attached_entry_ids:
|
||||
return
|
||||
rd = _get_runtime_data(hass, entry_id)
|
||||
if rd and rd.coordinator:
|
||||
unsub_callbacks.append(rd.coordinator.async_add_listener(_forward_update))
|
||||
attached_entry_ids.add(entry_id)
|
||||
|
||||
# Register listeners on all existing coordinators
|
||||
entries = _get_object_entries(hass)
|
||||
for entry in entries:
|
||||
_attach_entry(entry.entry_id)
|
||||
|
||||
# Listen for new object entries added after subscription
|
||||
@callback
|
||||
def _on_new_entry(entry_id: str) -> None:
|
||||
_attach_entry(entry_id)
|
||||
_forward_update()
|
||||
|
||||
unsub_callbacks.append(async_dispatcher_connect(hass, SIGNAL_NEW_OBJECT_ENTRY, _on_new_entry))
|
||||
|
||||
@callback
|
||||
def _unsub() -> None:
|
||||
for unsub in unsub_callbacks:
|
||||
unsub()
|
||||
|
||||
connection.subscriptions[msg["id"]] = _unsub
|
||||
|
||||
# Send initial data
|
||||
connection.send_result(msg["id"])
|
||||
_forward_update()
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/budget_status"})
|
||||
@websocket_api.async_response
|
||||
async def ws_get_budget_status(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return current budget status (monthly/yearly spent vs budget)."""
|
||||
from datetime import datetime as dt_cls
|
||||
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
global_entry = _get_global_entry(hass)
|
||||
global_options: Mapping[str, Any] = (global_entry.options or global_entry.data) if global_entry else {}
|
||||
|
||||
monthly_budget = float(global_options.get(CONF_BUDGET_MONTHLY, 0))
|
||||
yearly_budget = float(global_options.get(CONF_BUDGET_YEARLY, 0))
|
||||
threshold_pct = int(global_options.get(CONF_BUDGET_ALERT_THRESHOLD, 80))
|
||||
|
||||
now = dt_util.now()
|
||||
monthly_spent = 0.0
|
||||
yearly_spent = 0.0
|
||||
|
||||
entries = _get_object_entries(hass)
|
||||
for entry in entries:
|
||||
rd = _get_runtime_data(hass, entry.entry_id)
|
||||
store = getattr(rd, "store", None) if rd else None
|
||||
|
||||
for tid in entry.data.get("tasks", {}):
|
||||
if store is not None:
|
||||
history = store.get_history(tid)
|
||||
else:
|
||||
history = entry.data.get("tasks", {}).get(tid, {}).get("history", [])
|
||||
|
||||
for h_entry in history:
|
||||
if h_entry.get("type") != "completed":
|
||||
continue
|
||||
cost = h_entry.get("cost")
|
||||
if not isinstance(cost, (int, float)):
|
||||
continue
|
||||
ts = h_entry.get("timestamp", "")
|
||||
try:
|
||||
entry_dt = dt_cls.fromisoformat(ts)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
# Naive timestamps from older entries: treat as HA local TZ,
|
||||
# then normalise so year/month boundaries match `now`.
|
||||
if entry_dt.tzinfo is None:
|
||||
entry_dt = entry_dt.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE)
|
||||
entry_dt = dt_util.as_local(entry_dt)
|
||||
if entry_dt.year == now.year:
|
||||
yearly_spent += cost
|
||||
if entry_dt.month == now.month:
|
||||
monthly_spent += cost
|
||||
|
||||
currency_code = str(global_options.get(CONF_BUDGET_CURRENCY, DEFAULT_BUDGET_CURRENCY))
|
||||
currency_symbol = BUDGET_CURRENCIES.get(currency_code, "€")
|
||||
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
"monthly_budget": monthly_budget,
|
||||
"monthly_spent": round(monthly_spent, 2),
|
||||
"yearly_budget": yearly_budget,
|
||||
"yearly_spent": round(yearly_spent, 2),
|
||||
"alert_threshold_pct": threshold_pct,
|
||||
"currency_symbol": currency_symbol,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global settings update
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): f"{DOMAIN}/global/update",
|
||||
vol.Required("settings"): dict,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def ws_update_global_settings(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Update global settings.
|
||||
|
||||
Accepts a flat dict of setting keys to update. Unknown keys are
|
||||
silently ignored. Returns the full updated settings.
|
||||
"""
|
||||
global_entry = _get_global_entry(hass)
|
||||
if global_entry is None:
|
||||
connection.send_error(msg["id"], "not_found", "Global config entry not found")
|
||||
return
|
||||
|
||||
settings_input: dict[str, Any] = msg["settings"]
|
||||
|
||||
# Filter to allowed keys and validate types
|
||||
filtered: dict[str, Any] = {}
|
||||
for key, expected_type in _ALLOWED_SETTING_KEYS.items():
|
||||
if key in settings_input:
|
||||
val = settings_input[key]
|
||||
# Accept int for float fields
|
||||
if expected_type is float and isinstance(val, int):
|
||||
val = float(val)
|
||||
if isinstance(val, expected_type):
|
||||
filtered[key] = val
|
||||
|
||||
# Range-validate numeric and string settings against the shared registry.
|
||||
for key, (lo, hi) in INT_RANGES.items():
|
||||
if key in filtered and not (lo <= filtered[key] <= hi):
|
||||
del filtered[key]
|
||||
for key, (flo, fhi) in FLOAT_RANGES.items():
|
||||
if key in filtered:
|
||||
v = filtered[key]
|
||||
if not math.isfinite(v) or not (flo <= v <= fhi):
|
||||
del filtered[key]
|
||||
for key, max_len in STR_MAX_LENGTHS.items():
|
||||
if key in filtered and len(filtered[key]) > max_len:
|
||||
del filtered[key]
|
||||
|
||||
# Sidebar panel title (#63): trim + cap rather than drop, so an over-long
|
||||
# or padded value is normalised instead of silently ignored. A blank value
|
||||
# is kept (it clears the override → panel falls back to the default title).
|
||||
if CONF_PANEL_TITLE in filtered:
|
||||
raw_title = filtered[CONF_PANEL_TITLE]
|
||||
if isinstance(raw_title, str):
|
||||
filtered[CONF_PANEL_TITLE] = raw_title.strip()[:MAX_PANEL_TITLE_LENGTH]
|
||||
else:
|
||||
del filtered[CONF_PANEL_TITLE]
|
||||
|
||||
# v1.4.0 (#44): enum-validate notification_title_style. Anything outside
|
||||
# the known set is dropped silently so a bogus value can't get into the
|
||||
# ConfigEntry options.
|
||||
from ..const import NOTIFICATION_TITLE_STYLES
|
||||
|
||||
if CONF_NOTIFICATION_TITLE_STYLE in filtered and filtered[CONF_NOTIFICATION_TITLE_STYLE] not in NOTIFICATION_TITLE_STYLES:
|
||||
del filtered[CONF_NOTIFICATION_TITLE_STYLE]
|
||||
|
||||
# v1.4.6 (#44 follow-up): drop quiet-hours time strings that aren't valid
|
||||
# HH:MM[:SS]. The HA TimeSelector in the options-flow rejects empty / bad
|
||||
# strings as "Invalid time" and that error blocks the entire form save —
|
||||
# even when the user is here to change something else and quiet_hours is
|
||||
# disabled. By dropping invalid values here, the form falls back to the
|
||||
# 22:00 / 08:00 defaults next render.
|
||||
for time_key in (CONF_QUIET_HOURS_START, CONF_QUIET_HOURS_END):
|
||||
if time_key in filtered:
|
||||
v = filtered[time_key]
|
||||
if not isinstance(v, str) or not TIME_HHMMSS_PATTERN.match(v):
|
||||
del filtered[time_key]
|
||||
|
||||
# Sanitise admin_panel_user_ids: drop non-string entries + whitespace-only
|
||||
# entries, cap each at 64 chars (HA user UUIDs are 32), cap list at 50
|
||||
# entries, dedupe.
|
||||
if CONF_ADMIN_PANEL_USER_IDS in filtered:
|
||||
raw = filtered[CONF_ADMIN_PANEL_USER_IDS]
|
||||
cleaned: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for v in raw:
|
||||
if not isinstance(v, str):
|
||||
continue
|
||||
stripped = v.strip()
|
||||
if not stripped or len(stripped) > 64:
|
||||
continue
|
||||
if stripped in seen:
|
||||
continue
|
||||
seen.add(stripped)
|
||||
cleaned.append(stripped)
|
||||
if len(cleaned) >= 50:
|
||||
break
|
||||
filtered[CONF_ADMIN_PANEL_USER_IDS] = cleaned
|
||||
|
||||
# Multiple lead-time reminders: keep only ints within 0..365, dedupe, sort
|
||||
# descending (furthest lead first), cap the list. Empty list is valid — it
|
||||
# turns the feature off.
|
||||
if CONF_REMINDER_LEAD_DAYS in filtered:
|
||||
raw_leads = filtered[CONF_REMINDER_LEAD_DAYS]
|
||||
leads: list[int] = []
|
||||
for v in raw_leads:
|
||||
if isinstance(v, bool) or not isinstance(v, int):
|
||||
continue
|
||||
if 0 <= v <= 365 and v not in leads:
|
||||
leads.append(v)
|
||||
filtered[CONF_REMINDER_LEAD_DAYS] = sorted(leads, reverse=True)[:MAX_REMINDER_LEADS]
|
||||
|
||||
# (#67): objects_table_columns — keep only known column keys, preserve the
|
||||
# caller's order, dedupe. An empty/invalid result falls back to the default
|
||||
# set (the panel also defaults defensively).
|
||||
if CONF_OBJECTS_TABLE_COLUMNS in filtered:
|
||||
raw_cols = filtered[CONF_OBJECTS_TABLE_COLUMNS]
|
||||
cols: list[str] = []
|
||||
seen_cols: set[str] = set()
|
||||
for v in raw_cols:
|
||||
if not isinstance(v, str) or v not in KNOWN_OBJECT_TABLE_COLUMNS:
|
||||
continue
|
||||
if v in seen_cols:
|
||||
continue
|
||||
seen_cols.add(v)
|
||||
cols.append(v)
|
||||
filtered[CONF_OBJECTS_TABLE_COLUMNS] = cols or list(DEFAULT_OBJECTS_TABLE_COLUMNS)
|
||||
|
||||
# v2.21: disabled_template_ids — keep only ids of templates that actually
|
||||
# exist (a typo/stale id must not linger invisibly), dedupe.
|
||||
if CONF_DISABLED_TEMPLATE_IDS in filtered:
|
||||
from ..templates import KNOWN_TEMPLATE_IDS
|
||||
|
||||
raw_tids = filtered[CONF_DISABLED_TEMPLATE_IDS]
|
||||
tids: list[str] = []
|
||||
for v in raw_tids:
|
||||
if isinstance(v, str) and v in KNOWN_TEMPLATE_IDS and v not in tids:
|
||||
tids.append(v)
|
||||
filtered[CONF_DISABLED_TEMPLATE_IDS] = tids
|
||||
|
||||
if not filtered:
|
||||
connection.send_error(msg["id"], "invalid_input", "No valid setting keys provided")
|
||||
return
|
||||
|
||||
# Validate notify_service if provided
|
||||
if CONF_NOTIFY_SERVICE in filtered:
|
||||
from ..config_flow_options_global import validate_notify_service
|
||||
|
||||
normalized, error = validate_notify_service(filtered[CONF_NOTIFY_SERVICE])
|
||||
if error:
|
||||
connection.send_error(msg["id"], error, f"Invalid notify service: {error}")
|
||||
return
|
||||
filtered[CONF_NOTIFY_SERVICE] = normalized
|
||||
|
||||
# Merge with existing options
|
||||
merged = dict(global_entry.options or global_entry.data)
|
||||
merged.update(filtered)
|
||||
hass.config_entries.async_update_entry(global_entry, options=merged)
|
||||
|
||||
_LOGGER.debug("Global settings updated via WS: %s", list(filtered.keys()))
|
||||
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
_build_full_settings(
|
||||
merged,
|
||||
notify_targets=build_notify_targets(hass, current=merged.get(CONF_NOTIFY_SERVICE, "")),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test notification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/global/test_notification"})
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def ws_test_notification(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Send a test notification using the configured service."""
|
||||
from ..config_flow_options_global import (
|
||||
_get_test_result_text,
|
||||
send_test_notification,
|
||||
)
|
||||
|
||||
global_entry = _get_global_entry(hass)
|
||||
if global_entry is None:
|
||||
connection.send_error(msg["id"], "not_found", "Global config entry not found")
|
||||
return
|
||||
|
||||
options = dict(global_entry.options or global_entry.data)
|
||||
result_key = await send_test_notification(hass, options)
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
"success": result_key == "success",
|
||||
"message": _get_test_result_text(hass, result_key),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,248 @@
|
||||
"""WebSocket handlers for document metadata.
|
||||
|
||||
Covers everything that is pure JSON: listing an object's documents, attaching a
|
||||
web-link, editing metadata (title / tags / task links), deleting, and the global
|
||||
storage summary. Binary **file** upload + download go through the authenticated
|
||||
HTTP views in ``views.py`` (multipart / streamed body) — websocket frames are a
|
||||
poor fit for large binaries.
|
||||
|
||||
Authz mirrors object/task CRUD: reads (``list`` / ``storage``) are open to any
|
||||
panel user; mutations require write permission (admin or delegated operator) via
|
||||
``@require_write``. Documents are object content, the same tier as objects and
|
||||
tasks — never the global-config tier.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..const import (
|
||||
CONF_OBJECT,
|
||||
DOMAIN,
|
||||
GLOBAL_UNIQUE_ID,
|
||||
MAX_ID_LENGTH,
|
||||
MAX_NAME_LENGTH,
|
||||
MAX_URL_LENGTH,
|
||||
)
|
||||
from ..helpers.permissions import require_write
|
||||
from . import _load_object_entry, object_id_for_entry
|
||||
from .tasks import _is_safe_url
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..helpers.documents import DocumentStore
|
||||
|
||||
_MAX_TAGS = 20
|
||||
_MAX_TAG_LEN = 64
|
||||
_MAX_TASK_IDS = 100
|
||||
|
||||
# A list of non-empty, length-capped tag strings, itself length-capped.
|
||||
_TAGS_SCHEMA = vol.All(
|
||||
[vol.All(str, vol.Length(min=1, max=_MAX_TAG_LEN))],
|
||||
vol.Length(max=_MAX_TAGS),
|
||||
)
|
||||
_TASK_IDS_SCHEMA = vol.All(
|
||||
[vol.All(str, vol.Length(max=MAX_ID_LENGTH))],
|
||||
vol.Length(max=_MAX_TASK_IDS),
|
||||
)
|
||||
# {task_id: page} jump-to-page hints; page 0 clears, >=1 sets (PDFs, #page=N).
|
||||
# A plain ``str`` type-key matches any task id — a ``vol.All(str, ...)`` key is
|
||||
# treated as an unknown key by voluptuous and rejected as "extra keys".
|
||||
_TASK_PAGES_SCHEMA = vol.Schema({str: vol.All(int, vol.Range(min=0, max=99999))})
|
||||
|
||||
|
||||
def _get_store(hass: HomeAssistant) -> DocumentStore:
|
||||
"""Return the loaded document store (an async_setup invariant)."""
|
||||
from .. import DOCUMENT_STORE_KEY
|
||||
|
||||
store: DocumentStore = hass.data[DOMAIN][DOCUMENT_STORE_KEY]
|
||||
return store
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/documents/list",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_documents_list(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""List all documents attached to an object (newest first)."""
|
||||
entry = _load_object_entry(hass, connection, msg)
|
||||
if entry is None:
|
||||
return
|
||||
store = _get_store(hass)
|
||||
documents = store.for_object(object_id_for_entry(entry))
|
||||
connection.send_result(msg["id"], {"documents": documents})
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "maintenance_supporter/documents/storage"})
|
||||
@websocket_api.async_response
|
||||
async def ws_documents_storage(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return the global storage summary (physical vs logical, per object/category)."""
|
||||
connection.send_result(msg["id"], _get_store(hass).storage_summary())
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/documents/add_link",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Required("url"): vol.All(str, vol.Length(min=1, max=MAX_URL_LENGTH)),
|
||||
vol.Optional("title"): vol.Any(vol.All(str, vol.Length(max=MAX_NAME_LENGTH)), None),
|
||||
vol.Optional("tags"): _TAGS_SCHEMA,
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_documents_add_link(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Attach an external web-link to an object (0 storage, not in backups)."""
|
||||
entry = _load_object_entry(hass, connection, msg)
|
||||
if entry is None:
|
||||
return
|
||||
|
||||
url = msg["url"].strip()
|
||||
# Web-links must be absolute http/https — a relative or scheme-less URL is
|
||||
# meaningless as a stored external reference (and _is_safe_url treats those
|
||||
# as "safe", so the explicit prefix check is required here).
|
||||
if not url.lower().startswith(("http://", "https://")) or not _is_safe_url(url):
|
||||
connection.send_error(msg["id"], "invalid_url", "Only http/https URLs are allowed")
|
||||
return
|
||||
|
||||
title = msg.get("title")
|
||||
doc = await _get_store(hass).async_add_weblink(
|
||||
object_id_for_entry(entry),
|
||||
url=url,
|
||||
title=title.strip() if isinstance(title, str) and title.strip() else None,
|
||||
tags=msg.get("tags"),
|
||||
)
|
||||
connection.send_result(msg["id"], doc)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/documents/update",
|
||||
vol.Required("doc_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Optional("title"): vol.All(str, vol.Length(max=MAX_NAME_LENGTH)),
|
||||
vol.Optional("tags"): _TAGS_SCHEMA,
|
||||
vol.Optional("task_ids"): _TASK_IDS_SCHEMA,
|
||||
vol.Optional("task_pages"): _TASK_PAGES_SCHEMA,
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_documents_update(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Update editable document metadata (title / tags / task links / per-task page)."""
|
||||
store = _get_store(hass)
|
||||
title = msg.get("title")
|
||||
# A present title (even empty) sets/clears it; absent or null → no change.
|
||||
# Don't collapse "" to None here — that would make an intentional clear read
|
||||
# as "leave unchanged" and the old title would survive (L5).
|
||||
ok = await store.async_update(
|
||||
msg["doc_id"],
|
||||
title=title.strip() if isinstance(title, str) else None,
|
||||
tags=msg.get("tags"),
|
||||
task_ids=msg.get("task_ids"),
|
||||
task_pages=msg.get("task_pages"),
|
||||
)
|
||||
if not ok:
|
||||
connection.send_error(msg["id"], "not_found", "Document not found")
|
||||
return
|
||||
connection.send_result(msg["id"], store.get(msg["doc_id"]))
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/documents/delete",
|
||||
vol.Required("doc_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_documents_delete(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Remove a document. Frees blob bytes only when the last reference goes."""
|
||||
store = _get_store(hass)
|
||||
if store.get(msg["doc_id"]) is None:
|
||||
connection.send_error(msg["id"], "not_found", "Document not found")
|
||||
return
|
||||
freed = await store.async_remove(msg["doc_id"])
|
||||
connection.send_result(msg["id"], {"success": True, "bytes_freed": freed})
|
||||
|
||||
|
||||
_SEARCH_FIELDS = ("title", "filename", "url", "mime")
|
||||
_SEARCH_MAX_RESULTS = 50
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/documents/search",
|
||||
vol.Required("query"): vol.All(str, vol.Length(max=MAX_NAME_LENGTH)),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_documents_search(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Find documents across all objects by title / filename / tag (read, open)."""
|
||||
query = msg["query"].strip().lower()
|
||||
if not query:
|
||||
connection.send_result(msg["id"], {"results": []})
|
||||
return
|
||||
|
||||
# object id -> (entry_id, name), so hits carry a human-readable location.
|
||||
obj_map: dict[str, tuple[str, str]] = {}
|
||||
for entry in hass.config_entries.async_entries(DOMAIN):
|
||||
if entry.unique_id == GLOBAL_UNIQUE_ID:
|
||||
continue
|
||||
obj = entry.data.get(CONF_OBJECT, {})
|
||||
oid = obj.get("id")
|
||||
if isinstance(oid, str) and oid:
|
||||
obj_map[oid] = (entry.entry_id, obj.get("name", ""))
|
||||
|
||||
store = _get_store(hass)
|
||||
results: list[dict[str, Any]] = []
|
||||
for did, doc in store.documents.items():
|
||||
haystack = " ".join([str(doc.get(f) or "") for f in _SEARCH_FIELDS] + list(doc.get("tags") or [])).lower()
|
||||
if query not in haystack:
|
||||
continue
|
||||
entry_id, name = obj_map.get(doc.get("object_id", ""), ("", ""))
|
||||
results.append(
|
||||
{
|
||||
"id": did,
|
||||
"entry_id": entry_id,
|
||||
"object_name": name,
|
||||
"kind": doc.get("kind"),
|
||||
"title": doc.get("title"),
|
||||
"filename": doc.get("filename"),
|
||||
"url": doc.get("url"),
|
||||
"size": doc.get("size"),
|
||||
"tags": doc.get("tags") or [],
|
||||
}
|
||||
)
|
||||
if len(results) >= _SEARCH_MAX_RESULTS:
|
||||
break
|
||||
connection.send_result(msg["id"], {"results": results})
|
||||
@@ -0,0 +1,181 @@
|
||||
"""WebSocket handlers for group CRUD operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..const import (
|
||||
DOMAIN,
|
||||
MAX_GROUP_TASK_REFS,
|
||||
MAX_ID_LENGTH,
|
||||
MAX_NAME_LENGTH,
|
||||
MAX_TEXT_LENGTH,
|
||||
)
|
||||
from ..helpers.permissions import require_write
|
||||
from . import _get_global_entry
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/groups"})
|
||||
@websocket_api.async_response
|
||||
async def ws_get_groups(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return all maintenance groups."""
|
||||
from ..const import CONF_GROUPS
|
||||
|
||||
global_entry = _get_global_entry(hass)
|
||||
if global_entry is None:
|
||||
connection.send_result(msg["id"], {"groups": {}})
|
||||
return
|
||||
|
||||
options = global_entry.options or global_entry.data
|
||||
groups = options.get(CONF_GROUPS, {})
|
||||
connection.send_result(msg["id"], {"groups": groups})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): f"{DOMAIN}/group/create",
|
||||
vol.Required("name"): vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)),
|
||||
vol.Optional("description", default=""): vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)),
|
||||
vol.Optional("task_refs", default=[]): vol.All(
|
||||
[
|
||||
{
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
}
|
||||
],
|
||||
vol.Length(max=MAX_GROUP_TASK_REFS),
|
||||
),
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_create_group(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Create a new maintenance group."""
|
||||
from ..const import CONF_GROUPS
|
||||
|
||||
global_entry = _get_global_entry(hass)
|
||||
if global_entry is None:
|
||||
connection.send_error(msg["id"], "not_found", "Global config not found")
|
||||
return
|
||||
|
||||
name = msg["name"].strip()
|
||||
if not name:
|
||||
connection.send_error(msg["id"], "invalid_input", "Name must not be empty")
|
||||
return
|
||||
|
||||
group_id = uuid4().hex
|
||||
options = dict(global_entry.options or global_entry.data)
|
||||
groups = dict(options.get(CONF_GROUPS, {}))
|
||||
groups[group_id] = {
|
||||
"name": name,
|
||||
"description": msg.get("description", ""),
|
||||
"task_refs": msg.get("task_refs", []),
|
||||
}
|
||||
options[CONF_GROUPS] = groups
|
||||
hass.config_entries.async_update_entry(global_entry, options=options)
|
||||
|
||||
connection.send_result(msg["id"], {"group_id": group_id})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): f"{DOMAIN}/group/update",
|
||||
vol.Required("group_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Optional("name"): vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)),
|
||||
vol.Optional("description"): vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)),
|
||||
vol.Optional("task_refs"): vol.All(
|
||||
[
|
||||
{
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
}
|
||||
],
|
||||
vol.Length(max=MAX_GROUP_TASK_REFS),
|
||||
),
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_update_group(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Update an existing maintenance group."""
|
||||
from ..const import CONF_GROUPS
|
||||
|
||||
global_entry = _get_global_entry(hass)
|
||||
if global_entry is None:
|
||||
connection.send_error(msg["id"], "not_found", "Global config not found")
|
||||
return
|
||||
|
||||
options = dict(global_entry.options or global_entry.data)
|
||||
groups = dict(options.get(CONF_GROUPS, {}))
|
||||
group_id = msg["group_id"]
|
||||
|
||||
if group_id not in groups:
|
||||
connection.send_error(msg["id"], "not_found", "Group not found")
|
||||
return
|
||||
|
||||
group = dict(groups[group_id])
|
||||
if "name" in msg:
|
||||
group["name"] = msg["name"]
|
||||
if "description" in msg:
|
||||
group["description"] = msg["description"]
|
||||
if "task_refs" in msg:
|
||||
group["task_refs"] = msg["task_refs"]
|
||||
|
||||
groups[group_id] = group
|
||||
options[CONF_GROUPS] = groups
|
||||
hass.config_entries.async_update_entry(global_entry, options=options)
|
||||
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): f"{DOMAIN}/group/delete",
|
||||
vol.Required("group_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_delete_group(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Delete a maintenance group."""
|
||||
from ..const import CONF_GROUPS
|
||||
|
||||
global_entry = _get_global_entry(hass)
|
||||
if global_entry is None:
|
||||
connection.send_error(msg["id"], "not_found", "Global config not found")
|
||||
return
|
||||
|
||||
options = dict(global_entry.options or global_entry.data)
|
||||
groups = dict(options.get(CONF_GROUPS, {}))
|
||||
group_id = msg["group_id"]
|
||||
|
||||
if group_id not in groups:
|
||||
connection.send_error(msg["id"], "not_found", "Group not found")
|
||||
return
|
||||
|
||||
del groups[group_id]
|
||||
options[CONF_GROUPS] = groups
|
||||
hass.config_entries.async_update_entry(global_entry, options=options)
|
||||
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
@@ -0,0 +1,690 @@
|
||||
"""WebSocket handlers for export, import, CSV, QR, and templates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json as json_mod
|
||||
import logging
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..const import (
|
||||
CONF_OBJECT,
|
||||
CONF_OBJECT_MANUFACTURER,
|
||||
CONF_OBJECT_MODEL,
|
||||
CONF_OBJECT_NAME,
|
||||
CONF_TASKS,
|
||||
DOMAIN,
|
||||
GLOBAL_UNIQUE_ID,
|
||||
MAX_CHECKLIST_ITEM_LENGTH,
|
||||
MAX_CHECKLIST_ITEMS,
|
||||
MAX_ID_LENGTH,
|
||||
)
|
||||
from ..helpers.global_options import get_default_warning_days
|
||||
from ..helpers.qr_generator import (
|
||||
_ACTION_ICON_MAP,
|
||||
build_qr_url,
|
||||
generate_qr_svg,
|
||||
generate_qr_svg_data_uri,
|
||||
)
|
||||
from ..websocket.tasks import _check_nfc_tag_duplicate, _validate_trigger_config
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): f"{DOMAIN}/templates",
|
||||
# v2.21.1: the caller's UI language — template/task names arrive
|
||||
# localized. Falls back to the server language.
|
||||
vol.Optional("language"): vol.All(str, vol.Length(max=10)),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_get_templates(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return all maintenance templates.
|
||||
|
||||
Every template is returned with a ``disabled`` flag (v2.21 gallery
|
||||
curation): the pickers hide disabled ones client-side, while the Settings
|
||||
section needs the full list to render the toggles.
|
||||
"""
|
||||
from ..helpers.i18n import normalize_language
|
||||
from ..templates import (
|
||||
TEMPLATE_CATEGORIES,
|
||||
TEMPLATES,
|
||||
get_disabled_template_ids,
|
||||
localize_template_text,
|
||||
)
|
||||
|
||||
disabled = get_disabled_template_ids(hass)
|
||||
lang = (msg.get("language") or normalize_language(hass))[:2].lower()
|
||||
|
||||
result = {
|
||||
"categories": {cat_id: {k: v for k, v in cat.items()} for cat_id, cat in TEMPLATE_CATEGORIES.items()},
|
||||
"templates": [
|
||||
{
|
||||
"id": t.id,
|
||||
"name": localize_template_text(t.name, lang),
|
||||
"category": t.category,
|
||||
"disabled": t.id in disabled,
|
||||
"tasks": [
|
||||
{
|
||||
"name": localize_template_text(tt.name, lang),
|
||||
"type": tt.type,
|
||||
"schedule_type": tt.schedule_type,
|
||||
"interval_days": tt.interval_days,
|
||||
"warning_days": tt.warning_days,
|
||||
}
|
||||
for tt in t.tasks
|
||||
],
|
||||
}
|
||||
for t in TEMPLATES
|
||||
],
|
||||
}
|
||||
connection.send_result(msg["id"], result)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): f"{DOMAIN}/export",
|
||||
vol.Optional("format", default="json"): vol.In(["json", "yaml"]),
|
||||
vol.Optional("include_history", default=True): bool,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def ws_export_data(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Export all maintenance data as JSON or YAML."""
|
||||
from ..export import build_export_data, serialize_export
|
||||
|
||||
fmt = msg.get("format", "json")
|
||||
include_history = msg.get("include_history", True)
|
||||
|
||||
# Phase 1: gather data on the event loop (accesses HA APIs)
|
||||
data = build_export_data(hass, include_history=include_history)
|
||||
|
||||
# Phase 2: serialize in executor (CPU-bound, no HA API calls)
|
||||
result = await hass.async_add_executor_job(serialize_export, data, fmt)
|
||||
|
||||
connection.send_result(msg["id"], {"format": fmt, "data": result})
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/csv/export"})
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def ws_export_csv(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Export all maintenance data as CSV."""
|
||||
from ..helpers.csv_handler import export_objects_csv
|
||||
|
||||
csv_data = export_objects_csv(hass)
|
||||
connection.send_result(msg["id"], {"csv": csv_data})
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/objects/csv"})
|
||||
@websocket_api.async_response
|
||||
async def ws_export_objects_csv(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Export one row per maintenance object as CSV (#67).
|
||||
|
||||
Not admin-gated: it exposes only the asset fields the panel already sends
|
||||
to every user via ``maintenance_supporter/objects`` (no cost/history).
|
||||
"""
|
||||
from ..helpers.csv_handler import export_object_records_csv
|
||||
|
||||
csv_data = export_object_records_csv(hass)
|
||||
connection.send_result(msg["id"], {"csv": csv_data})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): f"{DOMAIN}/csv/import",
|
||||
vol.Required("csv_content"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def ws_import_csv(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Import maintenance objects from CSV content."""
|
||||
from ..helpers.csv_handler import import_objects_csv
|
||||
|
||||
csv_content = msg["csv_content"]
|
||||
# Guard against oversized payloads (max 1MB / 1000 objects)
|
||||
if len(csv_content) > 1_048_576:
|
||||
connection.send_error(msg["id"], "too_large", "CSV content exceeds 1MB limit")
|
||||
return
|
||||
|
||||
objects = import_objects_csv(csv_content, hass=hass)
|
||||
if len(objects) > 1000:
|
||||
connection.send_error(msg["id"], "too_many", "CSV contains more than 1000 objects")
|
||||
return
|
||||
|
||||
if not objects:
|
||||
connection.send_error(msg["id"], "empty_csv", "No valid objects found in CSV")
|
||||
return
|
||||
|
||||
created = []
|
||||
errors: list[dict[str, str]] = []
|
||||
for idx, obj_data in enumerate(objects):
|
||||
# Check for NFC tag duplicates in CSV-imported tasks
|
||||
nfc_warnings: list[str] = []
|
||||
for t_data in obj_data.get("tasks", {}).values():
|
||||
nfc_val = t_data.get("nfc_tag_id")
|
||||
if nfc_val:
|
||||
nfc_warn = _check_nfc_tag_duplicate(hass, nfc_val)
|
||||
if nfc_warn:
|
||||
nfc_warnings.append(nfc_warn)
|
||||
|
||||
try:
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": "websocket"},
|
||||
data={
|
||||
CONF_OBJECT: obj_data["object"],
|
||||
CONF_TASKS: obj_data["tasks"],
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
obj_name = obj_data.get("object", {}).get("name", f"row {idx + 1}")
|
||||
_LOGGER.exception("CSV import failed for %s", obj_name)
|
||||
errors.append({"name": obj_name, "reason": "unexpected error"})
|
||||
continue
|
||||
if result["type"] == "create_entry":
|
||||
entry_info: dict[str, Any] = {
|
||||
"entry_id": result["result"].entry_id,
|
||||
"name": obj_data["object"].get("name", ""),
|
||||
"task_count": len(obj_data["tasks"]),
|
||||
}
|
||||
if nfc_warnings:
|
||||
entry_info["warnings"] = nfc_warnings
|
||||
created.append(entry_info)
|
||||
else:
|
||||
obj_name = obj_data.get("object", {}).get("name", f"row {idx + 1}")
|
||||
errors.append({"name": obj_name, "reason": result.get("reason", "unknown")})
|
||||
|
||||
resp: dict[str, Any] = {
|
||||
"imported": created,
|
||||
"total": len(objects),
|
||||
"created": len(created),
|
||||
}
|
||||
if errors:
|
||||
resp["errors"] = errors
|
||||
connection.send_result(msg["id"], resp)
|
||||
|
||||
|
||||
def _parse_structured(raw: str) -> Any:
|
||||
"""Parse JSON *or* YAML export content into a Python object.
|
||||
|
||||
Both formats are accepted so every structured export (JSON and YAML)
|
||||
round-trips back through the importer. Raises ValueError if the content
|
||||
parses to neither a mapping nor a list.
|
||||
"""
|
||||
try:
|
||||
return json_mod.loads(raw)
|
||||
except (json_mod.JSONDecodeError, ValueError):
|
||||
pass
|
||||
import yaml # type: ignore[import-untyped]
|
||||
|
||||
try:
|
||||
loaded = yaml.safe_load(raw)
|
||||
except yaml.YAMLError as err:
|
||||
raise ValueError("not valid JSON or YAML") from err
|
||||
# safe_load returns a bare string/scalar for non-structured text (e.g. a
|
||||
# CSV blob) — require an object/array so those route elsewhere cleanly.
|
||||
if not isinstance(loaded, (dict, list)):
|
||||
raise ValueError("not valid JSON or YAML")
|
||||
return loaded
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): f"{DOMAIN}/json/import",
|
||||
vol.Required("json_content"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def ws_import_json(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Import maintenance objects from JSON or YAML content (from /export)."""
|
||||
raw = msg["json_content"]
|
||||
if len(raw) > 10_485_760:
|
||||
connection.send_error(msg["id"], "too_large", "Content exceeds 10MB limit")
|
||||
return
|
||||
|
||||
try:
|
||||
data = _parse_structured(raw)
|
||||
except ValueError:
|
||||
connection.send_error(msg["id"], "invalid_format", "Content is not valid JSON or YAML")
|
||||
return
|
||||
|
||||
if not isinstance(data, dict) or "objects" not in data:
|
||||
connection.send_error(msg["id"], "invalid_format", "JSON must contain an 'objects' array")
|
||||
return
|
||||
|
||||
objects = data["objects"]
|
||||
if not isinstance(objects, list):
|
||||
connection.send_error(msg["id"], "invalid_format", "'objects' must be an array")
|
||||
return
|
||||
|
||||
if len(objects) > 1000:
|
||||
connection.send_error(msg["id"], "too_many", "JSON contains more than 1000 objects")
|
||||
return
|
||||
|
||||
if not objects:
|
||||
connection.send_error(msg["id"], "empty", "No objects found in JSON")
|
||||
return
|
||||
|
||||
created = []
|
||||
errors: list[dict[str, str]] = []
|
||||
for idx, obj_entry in enumerate(objects):
|
||||
# Guard against malformed-but-schema-valid input (the schema only checks
|
||||
# json_content is a str): a non-dict entry / non-dict object would raise
|
||||
# AttributeError and escape the per-object try/except below.
|
||||
if not isinstance(obj_entry, dict):
|
||||
errors.append({"name": f"object {idx + 1}", "reason": "not an object"})
|
||||
continue
|
||||
obj_data = obj_entry.get("object", {})
|
||||
if not isinstance(obj_data, dict):
|
||||
errors.append({"name": f"object {idx + 1}", "reason": "invalid object data"})
|
||||
continue
|
||||
obj_name = (obj_data.get("name") or "").strip()
|
||||
if not obj_name:
|
||||
errors.append({"name": f"object {idx + 1}", "reason": "missing name"})
|
||||
continue
|
||||
|
||||
obj_id = uuid4().hex
|
||||
import_obj: dict[str, Any] = {
|
||||
"id": obj_id,
|
||||
"name": obj_name,
|
||||
"manufacturer": obj_data.get("manufacturer"),
|
||||
"model": obj_data.get("model"),
|
||||
"serial_number": obj_data.get("serial_number"),
|
||||
"area_id": obj_data.get("area_id"),
|
||||
"installation_date": obj_data.get("installation_date"),
|
||||
"warranty_expiry": obj_data.get("warranty_expiry"),
|
||||
# Imported counterparts of the export fields above; length-capped by
|
||||
# cap_object_fields and the frontend only renders http(s) doc URLs.
|
||||
"documentation_url": obj_data.get("documentation_url"),
|
||||
"notes": obj_data.get("notes"),
|
||||
# 2.19: device link / parent hierarchy — same-instance restores
|
||||
# keep them valid; stale ids degrade gracefully at read time.
|
||||
"ha_device_id": obj_data.get("ha_device_id"),
|
||||
"parent_entry_id": obj_data.get("parent_entry_id"),
|
||||
# 2.20: seasonal pause round-trips (a paused pool restored in
|
||||
# winter stays paused); replace-flow lineage ids are the same
|
||||
# instance-specific story as parent_entry_id above.
|
||||
"paused_at": obj_data.get("paused_at"),
|
||||
"paused_until": obj_data.get("paused_until"),
|
||||
"predecessor_entry_id": obj_data.get("predecessor_entry_id"),
|
||||
"replaced_by_entry_id": obj_data.get("replaced_by_entry_id"),
|
||||
"task_ids": [],
|
||||
}
|
||||
|
||||
import_tasks: dict[str, dict[str, Any]] = {}
|
||||
tasks_list = obj_entry.get("tasks", [])
|
||||
if not isinstance(tasks_list, list):
|
||||
tasks_list = []
|
||||
for task_entry in tasks_list:
|
||||
if not isinstance(task_entry, dict):
|
||||
continue
|
||||
task_name = (task_entry.get("name") or "").strip()
|
||||
if not task_name:
|
||||
continue
|
||||
task_id = uuid4().hex
|
||||
task_data: dict[str, Any] = {
|
||||
"id": task_id,
|
||||
"object_id": obj_id,
|
||||
"name": task_name,
|
||||
"type": task_entry.get("type", "custom"),
|
||||
"enabled": task_entry.get("enabled", True),
|
||||
"schedule_type": task_entry.get("schedule_type", "time_based"),
|
||||
"warning_days": task_entry.get("warning_days", get_default_warning_days(hass)),
|
||||
"history": task_entry.get("history", []),
|
||||
}
|
||||
for key in (
|
||||
"interval_days",
|
||||
"interval_unit",
|
||||
"due_date",
|
||||
"interval_anchor",
|
||||
"last_planned_due",
|
||||
# nested recurrence (calendar kinds) — config-flow normalize
|
||||
# treats it as authoritative when present.
|
||||
"schedule",
|
||||
"last_performed",
|
||||
"notes",
|
||||
"documentation_url",
|
||||
"custom_icon",
|
||||
"nfc_tag_id",
|
||||
"responsible_user_id",
|
||||
"entity_slug",
|
||||
"trigger_config",
|
||||
"adaptive_config",
|
||||
"checklist",
|
||||
"schedule_time",
|
||||
):
|
||||
val = task_entry.get(key)
|
||||
if val is not None:
|
||||
task_data[key] = val
|
||||
|
||||
# Sanitize critical fields from import data
|
||||
iv = task_data.get("interval_days")
|
||||
if iv is not None and (not isinstance(iv, int) or iv < 1):
|
||||
task_data.pop("interval_days", None)
|
||||
lp = task_data.get("last_performed")
|
||||
if lp is not None:
|
||||
try:
|
||||
from datetime import date
|
||||
|
||||
date.fromisoformat(lp)
|
||||
except (ValueError, TypeError):
|
||||
task_data.pop("last_performed", None)
|
||||
wd = task_data.get("warning_days")
|
||||
if not isinstance(wd, int) or wd < 0 or wd > 365:
|
||||
task_data["warning_days"] = get_default_warning_days(hass)
|
||||
# Sanitize checklist: only keep string items within length budget,
|
||||
# cap total items. Drops malformed entries silently rather than
|
||||
# rejecting the whole import — same forgiving model as the other
|
||||
# fields above.
|
||||
cl = task_data.get("checklist")
|
||||
if cl is not None:
|
||||
if not isinstance(cl, list):
|
||||
task_data.pop("checklist", None)
|
||||
else:
|
||||
cleaned = [item.strip() for item in cl if isinstance(item, str) and len(item) <= MAX_CHECKLIST_ITEM_LENGTH]
|
||||
cleaned = [c for c in cleaned if c]
|
||||
task_data["checklist"] = cleaned[:MAX_CHECKLIST_ITEMS]
|
||||
|
||||
# schedule_time: strict HH:MM, otherwise drop
|
||||
st = task_data.get("schedule_time")
|
||||
if st is not None:
|
||||
if not isinstance(st, str) or not re.fullmatch(r"^([01]\d|2[0-3]):[0-5]\d$", st):
|
||||
task_data.pop("schedule_time", None)
|
||||
|
||||
# Validate an imported trigger_config the same way the WS create/update
|
||||
# path does — strip unknown keys, normalize entity_ids, and drop it
|
||||
# entirely if invalid — so import isn't a hole around trigger validation.
|
||||
tc = task_data.get("trigger_config")
|
||||
if isinstance(tc, dict):
|
||||
errors, _warnings = _validate_trigger_config(hass, tc)
|
||||
if errors:
|
||||
task_data.pop("trigger_config", None)
|
||||
elif tc is not None:
|
||||
task_data.pop("trigger_config", None)
|
||||
|
||||
import_tasks[task_id] = task_data
|
||||
import_obj["task_ids"].append(task_id)
|
||||
|
||||
# Check for NFC tag duplicates across imported tasks
|
||||
nfc_warnings: list[str] = []
|
||||
for t_data in import_tasks.values():
|
||||
nfc_val = t_data.get("nfc_tag_id")
|
||||
if nfc_val:
|
||||
nfc_warn = _check_nfc_tag_duplicate(hass, nfc_val)
|
||||
if nfc_warn:
|
||||
nfc_warnings.append(nfc_warn)
|
||||
|
||||
try:
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": "websocket"},
|
||||
data={
|
||||
CONF_OBJECT: import_obj,
|
||||
CONF_TASKS: import_tasks,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
_LOGGER.exception("JSON import failed for %s", obj_name)
|
||||
errors.append({"name": obj_name, "reason": "unexpected error"})
|
||||
continue
|
||||
if result["type"] == "create_entry":
|
||||
entry_info: dict[str, Any] = {
|
||||
"entry_id": result["result"].entry_id,
|
||||
"name": obj_name,
|
||||
"task_count": len(import_tasks),
|
||||
}
|
||||
if nfc_warnings:
|
||||
entry_info["warnings"] = nfc_warnings
|
||||
created.append(entry_info)
|
||||
|
||||
# (roadmap P6) recreate document metadata + web-links for the object
|
||||
# (blobs travel via the /config backup; a JSON-only import leaves
|
||||
# file docs dangling, which the storage-hygiene repair issue catches).
|
||||
import_docs = obj_entry.get("documents")
|
||||
if isinstance(import_docs, list) and import_docs:
|
||||
from .. import DOCUMENT_STORE_KEY
|
||||
|
||||
doc_store = hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY)
|
||||
if doc_store is not None:
|
||||
await doc_store.async_import_documents(obj_id, import_docs)
|
||||
else:
|
||||
errors.append({"name": obj_name, "reason": result.get("reason", "unknown")})
|
||||
|
||||
resp: dict[str, Any] = {
|
||||
"imported": created,
|
||||
"total": len(objects),
|
||||
"created": len(created),
|
||||
}
|
||||
if errors:
|
||||
resp["errors"] = errors
|
||||
connection.send_result(msg["id"], resp)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/qr/generate",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Optional("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Optional("action", default="view"): vol.In(["view", "complete", "quick_complete"]),
|
||||
vol.Optional("url_mode", default="server"): vol.In(["server", "local", "companion"]),
|
||||
vol.Optional("base_url"): vol.Url(),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_generate_qr(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Generate a QR code for a maintenance object or task."""
|
||||
entry_id = msg["entry_id"]
|
||||
entry = hass.config_entries.async_get_entry(entry_id)
|
||||
if entry is None or entry.domain != DOMAIN or entry.unique_id == GLOBAL_UNIQUE_ID:
|
||||
connection.send_error(msg["id"], "not_found", "Object not found")
|
||||
return
|
||||
|
||||
obj_data = entry.data.get(CONF_OBJECT, {})
|
||||
task_id = msg.get("task_id")
|
||||
task_name = None
|
||||
|
||||
if task_id:
|
||||
tasks_data = entry.data.get(CONF_TASKS, {})
|
||||
if task_id not in tasks_data:
|
||||
connection.send_error(msg["id"], "not_found", "Task not found")
|
||||
return
|
||||
task_name = tasks_data[task_id].get("name", "")
|
||||
|
||||
action = msg.get("action", "view")
|
||||
url_mode = msg.get("url_mode", "server")
|
||||
base_url = msg.get("base_url")
|
||||
try:
|
||||
url = build_qr_url(
|
||||
hass,
|
||||
entry_id,
|
||||
task_id=task_id,
|
||||
action=action,
|
||||
base_url_override=base_url,
|
||||
url_mode=url_mode,
|
||||
)
|
||||
except ValueError as err:
|
||||
connection.send_error(msg["id"], "no_url", str(err))
|
||||
return
|
||||
from functools import partial
|
||||
|
||||
icon = _ACTION_ICON_MAP.get(action)
|
||||
gen_fn = partial(generate_qr_svg_data_uri, url, border=2, icon=icon)
|
||||
svg_data_uri = await hass.async_add_executor_job(gen_fn)
|
||||
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
"svg_data_uri": svg_data_uri,
|
||||
"url": url,
|
||||
"label": {
|
||||
"object_name": obj_data.get(CONF_OBJECT_NAME, ""),
|
||||
"manufacturer": obj_data.get(CONF_OBJECT_MANUFACTURER, ""),
|
||||
"model": obj_data.get(CONF_OBJECT_MODEL, ""),
|
||||
"task_name": task_name,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Batch QR generation — used by the "Print QR codes" panel section.
|
||||
#
|
||||
# Typical household: 20-30 tasks × 2 actions = 40-60 QRs. Benchmarked at
|
||||
# ~40 ms each with icon embed (HIGH ECC) → 2.5 s for 60, 7 s for 200.
|
||||
# The raw SVG is ~32 KB each, so 200 × 32 KB = ~6 MB over the websocket;
|
||||
# we cap at 200 to keep the payload bounded and the print layout sane
|
||||
# (generous 6 QRs/A4 page = 34 pages).
|
||||
_MAX_BATCH_QRS = 200
|
||||
|
||||
|
||||
# LRU cache keyed on (url, icon). Two users printing the same task twice
|
||||
# in a session hit this cache; so does re-running the batch after
|
||||
# narrowing the filter. Bounded size so long-running HA instances with
|
||||
# thousands of task-action combos can't grow the cache forever.
|
||||
@lru_cache(maxsize=512)
|
||||
def _cached_qr_svg(url: str, icon: str | None) -> str:
|
||||
return generate_qr_svg(url, border=2, icon=icon)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/qr/batch_generate",
|
||||
vol.Optional("entry_ids"): vol.All(
|
||||
[vol.All(str, vol.Length(max=MAX_ID_LENGTH))],
|
||||
vol.Length(max=1000),
|
||||
),
|
||||
vol.Optional("task_ids"): vol.All(
|
||||
[vol.All(str, vol.Length(max=MAX_ID_LENGTH))],
|
||||
vol.Length(max=2000),
|
||||
),
|
||||
vol.Required("actions"): vol.All(
|
||||
[vol.In(["view", "complete", "skip", "quick_complete"])],
|
||||
vol.Length(min=1, max=4),
|
||||
),
|
||||
vol.Optional("url_mode", default="server"): vol.In(["server", "local", "companion"]),
|
||||
vol.Optional("base_url"): vol.Url(),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_batch_generate_qr(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Generate multiple QR codes in one call for the print-all-QRs page.
|
||||
|
||||
Resolves (entry × task × action) combinations and returns SVG strings
|
||||
ready to inline into a printable grid. Empty ``entry_ids`` / ``task_ids``
|
||||
filters mean "all" at that level.
|
||||
"""
|
||||
# Resolve target entries (always exclude the global config entry).
|
||||
all_entries = [entry for entry in hass.config_entries.async_entries(DOMAIN) if entry.unique_id != GLOBAL_UNIQUE_ID]
|
||||
entry_filter = msg.get("entry_ids")
|
||||
if entry_filter:
|
||||
wanted = set(entry_filter)
|
||||
entries = [e for e in all_entries if e.entry_id in wanted]
|
||||
else:
|
||||
entries = all_entries
|
||||
|
||||
# Build the flat (entry_id, object_name, task_id, task_name) target list,
|
||||
# honouring the optional task_ids filter.
|
||||
task_filter = set(msg["task_ids"]) if msg.get("task_ids") else None
|
||||
targets: list[tuple[str, str, str, str]] = []
|
||||
for entry in entries:
|
||||
obj_name = entry.data.get(CONF_OBJECT, {}).get(CONF_OBJECT_NAME, "")
|
||||
tasks_data = entry.data.get(CONF_TASKS, {})
|
||||
for task_id, task_data in tasks_data.items():
|
||||
if task_filter is not None and task_id not in task_filter:
|
||||
continue
|
||||
targets.append((entry.entry_id, obj_name, task_id, task_data.get("name", "")))
|
||||
|
||||
actions: list[str] = msg["actions"]
|
||||
total = len(targets) * len(actions)
|
||||
if total == 0:
|
||||
connection.send_result(msg["id"], {"qrs": [], "total": 0})
|
||||
return
|
||||
if total > _MAX_BATCH_QRS:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
"too_many",
|
||||
f"Batch would produce {total} QR codes; the per-request cap is "
|
||||
f"{_MAX_BATCH_QRS}. Narrow the object/task/action filter.",
|
||||
)
|
||||
return
|
||||
|
||||
url_mode = msg.get("url_mode", "server")
|
||||
base_url = msg.get("base_url")
|
||||
|
||||
# Generate URL first (fast), then offload the SVG encoding to the executor
|
||||
# since it's CPU-bound (~30-40 ms/QR). Each SVG passes through the LRU
|
||||
# cache so re-runs after a filter change are near-instant.
|
||||
results: list[dict[str, Any]] = []
|
||||
for entry_id, obj_name, task_id, task_name in targets:
|
||||
for action in actions:
|
||||
try:
|
||||
url = build_qr_url(
|
||||
hass,
|
||||
entry_id,
|
||||
task_id=task_id,
|
||||
action=action,
|
||||
base_url_override=base_url,
|
||||
url_mode=url_mode,
|
||||
)
|
||||
except ValueError:
|
||||
# No HA URL configured — skip this row rather than fail the
|
||||
# whole batch. "server" mode is the only path that raises;
|
||||
# "companion" and "local" always resolve.
|
||||
continue
|
||||
icon = _ACTION_ICON_MAP.get(action) # None for "skip" (no icon)
|
||||
svg = await hass.async_add_executor_job(_cached_qr_svg, url, icon)
|
||||
results.append(
|
||||
{
|
||||
"entry_id": entry_id,
|
||||
"task_id": task_id,
|
||||
"object_name": obj_name,
|
||||
"task_name": task_name,
|
||||
"action": action,
|
||||
"svg": svg,
|
||||
}
|
||||
)
|
||||
|
||||
connection.send_result(msg["id"], {"qrs": results, "total": len(results)})
|
||||
@@ -0,0 +1,929 @@
|
||||
"""WebSocket handlers for object CRUD operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from ..const import (
|
||||
ARCHIVE_REASON_OBJECT,
|
||||
CONF_OBJECT,
|
||||
CONF_OBJECT_AREA,
|
||||
CONF_OBJECT_DOCUMENTATION_URL,
|
||||
CONF_OBJECT_INSTALLATION_DATE,
|
||||
CONF_OBJECT_MANUFACTURER,
|
||||
CONF_OBJECT_MODEL,
|
||||
CONF_OBJECT_NAME,
|
||||
CONF_OBJECT_NOTES,
|
||||
CONF_OBJECT_SERIAL_NUMBER,
|
||||
CONF_OBJECT_WARRANTY_EXPIRY,
|
||||
CONF_TASKS,
|
||||
DOMAIN,
|
||||
GLOBAL_UNIQUE_ID,
|
||||
MAX_DATE_LENGTH,
|
||||
MAX_ENTITY_ID_LENGTH,
|
||||
MAX_ID_LENGTH,
|
||||
MAX_META_LENGTH,
|
||||
MAX_NAME_LENGTH,
|
||||
MAX_TEXT_LENGTH,
|
||||
MAX_URL_LENGTH,
|
||||
)
|
||||
from ..helpers.permissions import require_write
|
||||
from ..helpers.sanitize import cap_object_fields
|
||||
from . import (
|
||||
_build_object_response,
|
||||
_get_object_entries,
|
||||
_get_runtime_data,
|
||||
_load_object_entry,
|
||||
cleanup_group_refs,
|
||||
)
|
||||
from .tasks import ( # v1.4.0 (#43): reuse the existing URL safety check
|
||||
_is_recurring_schedule,
|
||||
_is_safe_url,
|
||||
)
|
||||
|
||||
# The optional object string fields are identical between object/create and
|
||||
# object/update — define them once so the two schemas can't drift. The caps
|
||||
# mirror helpers.sanitize._OBJECT_STR_LIMITS (a tripwire test enforces parity),
|
||||
# which is also applied on persist via cap_object_fields as a safety net.
|
||||
_OBJECT_STR_FIELD_SCHEMA: dict[Any, Any] = {
|
||||
vol.Optional("area_id"): vol.Any(vol.All(str, vol.Length(max=MAX_META_LENGTH)), None),
|
||||
vol.Optional("manufacturer"): vol.Any(vol.All(str, vol.Length(max=MAX_META_LENGTH)), None),
|
||||
vol.Optional("model"): vol.Any(vol.All(str, vol.Length(max=MAX_META_LENGTH)), None),
|
||||
vol.Optional("serial_number"): vol.Any(vol.All(str, vol.Length(max=MAX_META_LENGTH)), None),
|
||||
vol.Optional("installation_date"): vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None),
|
||||
vol.Optional("warranty_expiry"): vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None), # (#67)
|
||||
# v1.4.0 (#43): per-object link to PDF manual / vendor page
|
||||
vol.Optional("documentation_url"): vol.Any(vol.All(str, vol.Length(max=MAX_URL_LENGTH)), None),
|
||||
# v1.4.10 (#46): free-form notes (part numbers, procedures, etc.)
|
||||
vol.Optional("notes"): vol.Any(vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)), None),
|
||||
# 2.19: attach the object to an EXISTING HA device (entities land on its
|
||||
# device page) / nest under another maintenance object (via_device).
|
||||
vol.Optional("ha_device_id"): vol.Any(vol.All(str, vol.Length(max=MAX_ID_LENGTH)), None),
|
||||
vol.Optional("parent_entry_id"): vol.Any(vol.All(str, vol.Length(max=MAX_ID_LENGTH)), None),
|
||||
}
|
||||
|
||||
|
||||
def _validate_device_link(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
*,
|
||||
self_entry_id: str | None,
|
||||
) -> bool:
|
||||
"""Validate ha_device_id / parent_entry_id; sends the WS error itself.
|
||||
|
||||
The parent chain is walked upwards so an A->B->A cycle (which would make
|
||||
the via_device hierarchy unresolvable) is rejected at write time.
|
||||
"""
|
||||
if device_id := msg.get("ha_device_id"):
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
if dr.async_get(hass).async_get(device_id) is None:
|
||||
connection.send_error(msg["id"], "invalid_device", f"No HA device {device_id!r}")
|
||||
return False
|
||||
|
||||
if parent_id := msg.get("parent_entry_id"):
|
||||
parent = hass.config_entries.async_get_entry(parent_id)
|
||||
if parent is None or parent.domain != DOMAIN or parent.unique_id == GLOBAL_UNIQUE_ID:
|
||||
connection.send_error(msg["id"], "invalid_parent", f"No maintenance object {parent_id!r}")
|
||||
return False
|
||||
if self_entry_id is not None:
|
||||
cursor: str | None = parent_id
|
||||
for _ in range(20):
|
||||
if cursor == self_entry_id:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
"invalid_parent",
|
||||
"Parent chain would form a cycle",
|
||||
)
|
||||
return False
|
||||
cur = hass.config_entries.async_get_entry(cursor) if cursor else None
|
||||
cursor = (cur.data.get(CONF_OBJECT, {}) or {}).get("parent_entry_id") if cur else None
|
||||
if not cursor:
|
||||
break
|
||||
return True
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "maintenance_supporter/objects"})
|
||||
@websocket_api.async_response
|
||||
async def ws_get_objects(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return all maintenance objects with tasks and computed status."""
|
||||
entries = _get_object_entries(hass)
|
||||
result = []
|
||||
for entry in entries:
|
||||
rd = _get_runtime_data(hass, entry.entry_id)
|
||||
coord_data = rd.coordinator.data if rd and rd.coordinator else None
|
||||
result.append(_build_object_response(hass, entry, coord_data))
|
||||
|
||||
connection.send_result(msg["id"], {"objects": result})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/object",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_get_object(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return a single object with full task details including history."""
|
||||
entry_id = msg["entry_id"]
|
||||
entry = hass.config_entries.async_get_entry(entry_id)
|
||||
if entry is None or entry.domain != DOMAIN or entry.unique_id == GLOBAL_UNIQUE_ID:
|
||||
connection.send_error(msg["id"], "not_found", "Object not found")
|
||||
return
|
||||
|
||||
rd = _get_runtime_data(hass, entry_id)
|
||||
coord_data = rd.coordinator.data if rd and rd.coordinator else None
|
||||
connection.send_result(msg["id"], _build_object_response(hass, entry, coord_data))
|
||||
|
||||
|
||||
async def async_create_object(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
name: str,
|
||||
area_id: str | None = None,
|
||||
manufacturer: str | None = None,
|
||||
model: str | None = None,
|
||||
serial_number: str | None = None,
|
||||
installation_date: str | None = None,
|
||||
warranty_expiry: str | None = None,
|
||||
documentation_url: str | None = None,
|
||||
notes: str | None = None,
|
||||
ha_device_id: str | None = None,
|
||||
parent_entry_id: str | None = None,
|
||||
) -> str:
|
||||
"""Create a maintenance object (config entry) and return its entry_id.
|
||||
|
||||
Shared creation primitive for the ``object/create`` WS command and the
|
||||
``add_object`` service (DRY). Inputs are normalized here; callers do their
|
||||
own validation/error reporting (the WS layer keeps its specific error
|
||||
codes). Raises ValueError if the config flow does not create an entry.
|
||||
"""
|
||||
data = {
|
||||
CONF_OBJECT: {
|
||||
"id": uuid4().hex,
|
||||
CONF_OBJECT_NAME: name.strip(),
|
||||
CONF_OBJECT_AREA: area_id,
|
||||
CONF_OBJECT_MANUFACTURER: (manufacturer or "").strip() or None,
|
||||
CONF_OBJECT_MODEL: (model or "").strip() or None,
|
||||
CONF_OBJECT_SERIAL_NUMBER: (serial_number or "").strip() or None,
|
||||
CONF_OBJECT_INSTALLATION_DATE: installation_date,
|
||||
CONF_OBJECT_WARRANTY_EXPIRY: warranty_expiry,
|
||||
CONF_OBJECT_DOCUMENTATION_URL: (documentation_url or "").strip() or None,
|
||||
CONF_OBJECT_NOTES: (notes.strip() if isinstance(notes, str) and notes.strip() else None),
|
||||
"ha_device_id": ha_device_id,
|
||||
"parent_entry_id": parent_entry_id,
|
||||
"task_ids": [],
|
||||
},
|
||||
CONF_TASKS: {},
|
||||
}
|
||||
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "websocket"}, data=data)
|
||||
if result["type"] != "create_entry":
|
||||
raise ValueError(f"Failed to create object: {result.get('reason', 'unknown')}")
|
||||
return result["result"].entry_id
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/object/create",
|
||||
vol.Required("name"): vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)),
|
||||
**_OBJECT_STR_FIELD_SCHEMA,
|
||||
vol.Optional("dry_run", default=False): bool,
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_create_object(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Create a new maintenance object via config flow."""
|
||||
name = msg["name"].strip()
|
||||
if not name:
|
||||
connection.send_error(msg["id"], "invalid_input", "Name must not be empty")
|
||||
return
|
||||
|
||||
manufacturer = (msg.get("manufacturer") or "").strip() or None
|
||||
model = (msg.get("model") or "").strip() or None
|
||||
serial_number = (msg.get("serial_number") or "").strip() or None
|
||||
|
||||
# Validate installation_date format if provided
|
||||
installation_date = msg.get("installation_date")
|
||||
if installation_date:
|
||||
from datetime import date as date_cls
|
||||
|
||||
try:
|
||||
date_cls.fromisoformat(installation_date)
|
||||
except ValueError:
|
||||
connection.send_error(msg["id"], "invalid_date", "Invalid installation_date format (expected YYYY-MM-DD)")
|
||||
return
|
||||
|
||||
# (#67): validate warranty_expiry format if provided
|
||||
warranty_expiry = msg.get("warranty_expiry")
|
||||
if warranty_expiry:
|
||||
from datetime import date as date_cls
|
||||
|
||||
try:
|
||||
date_cls.fromisoformat(warranty_expiry)
|
||||
except ValueError:
|
||||
connection.send_error(msg["id"], "invalid_date", "Invalid warranty_expiry format (expected YYYY-MM-DD)")
|
||||
return
|
||||
|
||||
# v1.4.0 (#43): documentation_url
|
||||
documentation_url = (msg.get("documentation_url") or "").strip() or None
|
||||
if documentation_url and not _is_safe_url(documentation_url):
|
||||
connection.send_error(msg["id"], "invalid_url", "Only http/https URLs are allowed")
|
||||
return
|
||||
|
||||
# v1.4.10 (#46): notes (free-form, may contain newlines)
|
||||
notes_raw = msg.get("notes")
|
||||
notes = notes_raw.strip() if isinstance(notes_raw, str) and notes_raw.strip() else None
|
||||
|
||||
# 2.19: device link / parent hierarchy
|
||||
if not _validate_device_link(hass, connection, msg, self_entry_id=None):
|
||||
return
|
||||
|
||||
# Dry-run mode: validate only, do not persist
|
||||
if msg.get("dry_run"):
|
||||
connection.send_result(msg["id"], {"valid": True, "entry_id": None})
|
||||
return
|
||||
|
||||
try:
|
||||
entry_id = await async_create_object(
|
||||
hass,
|
||||
name=name,
|
||||
area_id=msg.get("area_id"),
|
||||
manufacturer=manufacturer,
|
||||
model=model,
|
||||
serial_number=serial_number,
|
||||
installation_date=installation_date,
|
||||
warranty_expiry=warranty_expiry,
|
||||
documentation_url=documentation_url,
|
||||
notes=notes,
|
||||
ha_device_id=msg.get("ha_device_id"),
|
||||
parent_entry_id=msg.get("parent_entry_id"),
|
||||
)
|
||||
except ValueError as err:
|
||||
connection.send_error(msg["id"], "create_failed", str(err))
|
||||
return
|
||||
connection.send_result(msg["id"], {"entry_id": entry_id})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/object/update",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Optional("name"): vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)),
|
||||
**_OBJECT_STR_FIELD_SCHEMA,
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_update_object(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Update an existing maintenance object."""
|
||||
entry = _load_object_entry(hass, connection, msg)
|
||||
if entry is None:
|
||||
return
|
||||
|
||||
# Strip and validate name if provided
|
||||
if "name" in msg:
|
||||
msg["name"] = msg["name"].strip()
|
||||
if not msg["name"]:
|
||||
connection.send_error(msg["id"], "invalid_input", "Name must not be empty")
|
||||
return
|
||||
|
||||
# Strip manufacturer/model/serial_number
|
||||
if msg.get("manufacturer"):
|
||||
msg["manufacturer"] = msg["manufacturer"].strip() or None
|
||||
if msg.get("model"):
|
||||
msg["model"] = msg["model"].strip() or None
|
||||
if msg.get("serial_number"):
|
||||
msg["serial_number"] = msg["serial_number"].strip() or None
|
||||
|
||||
# Validate installation_date format if provided
|
||||
if msg.get("installation_date"):
|
||||
from datetime import date as date_cls
|
||||
|
||||
try:
|
||||
date_cls.fromisoformat(msg["installation_date"])
|
||||
except ValueError:
|
||||
connection.send_error(msg["id"], "invalid_date", "Invalid installation_date format (expected YYYY-MM-DD)")
|
||||
return
|
||||
|
||||
# (#67): validate warranty_expiry format if provided
|
||||
if msg.get("warranty_expiry"):
|
||||
from datetime import date as date_cls
|
||||
|
||||
try:
|
||||
date_cls.fromisoformat(msg["warranty_expiry"])
|
||||
except ValueError:
|
||||
connection.send_error(msg["id"], "invalid_date", "Invalid warranty_expiry format (expected YYYY-MM-DD)")
|
||||
return
|
||||
|
||||
# v1.4.0 (#43): documentation_url
|
||||
if "documentation_url" in msg:
|
||||
if msg["documentation_url"] is not None:
|
||||
stripped = (msg["documentation_url"] or "").strip()
|
||||
msg["documentation_url"] = stripped or None
|
||||
if msg["documentation_url"] and not _is_safe_url(msg["documentation_url"]):
|
||||
connection.send_error(msg["id"], "invalid_url", "Only http/https URLs are allowed")
|
||||
return
|
||||
|
||||
# v1.4.10 (#46): notes — strip but keep newlines, empty -> None
|
||||
if "notes" in msg:
|
||||
if msg["notes"] is not None:
|
||||
stripped = msg["notes"].strip()
|
||||
msg["notes"] = stripped or None
|
||||
|
||||
# 2.19: device link / parent hierarchy
|
||||
if not _validate_device_link(hass, connection, msg, self_entry_id=entry.entry_id):
|
||||
return
|
||||
|
||||
new_data = dict(entry.data)
|
||||
obj = dict(new_data.get(CONF_OBJECT, {}))
|
||||
|
||||
if "name" in msg:
|
||||
# Per-task unique_ids embed the object's name slug — migrate the
|
||||
# entity registry on rename or the next reload orphans every entity.
|
||||
from ..helpers.entity_rename import migrate_object_unique_ids
|
||||
|
||||
migrate_object_unique_ids(hass, entry, obj.get(CONF_OBJECT_NAME), msg["name"])
|
||||
obj[CONF_OBJECT_NAME] = msg["name"]
|
||||
if "area_id" in msg:
|
||||
obj[CONF_OBJECT_AREA] = msg["area_id"]
|
||||
if "manufacturer" in msg:
|
||||
obj[CONF_OBJECT_MANUFACTURER] = msg["manufacturer"]
|
||||
if "model" in msg:
|
||||
obj[CONF_OBJECT_MODEL] = msg["model"]
|
||||
if "serial_number" in msg:
|
||||
obj[CONF_OBJECT_SERIAL_NUMBER] = msg["serial_number"]
|
||||
if "installation_date" in msg:
|
||||
obj[CONF_OBJECT_INSTALLATION_DATE] = msg["installation_date"]
|
||||
if "warranty_expiry" in msg:
|
||||
obj[CONF_OBJECT_WARRANTY_EXPIRY] = msg["warranty_expiry"]
|
||||
if "documentation_url" in msg:
|
||||
obj[CONF_OBJECT_DOCUMENTATION_URL] = msg["documentation_url"]
|
||||
if "notes" in msg:
|
||||
obj[CONF_OBJECT_NOTES] = msg["notes"]
|
||||
# 2.19: entity->device attachment only changes on entity re-add, so a
|
||||
# changed link/parent needs an entry reload (scheduled below).
|
||||
device_link_changed = False
|
||||
for key in ("ha_device_id", "parent_entry_id"):
|
||||
if key in msg and msg[key] != obj.get(key):
|
||||
obj[key] = msg[key]
|
||||
device_link_changed = True
|
||||
|
||||
# Safety net: cap user strings on persist so this write path matches the
|
||||
# create path (which caps via the config flow) and tasks (cap_task_fields).
|
||||
cap_object_fields(obj)
|
||||
new_data[CONF_OBJECT] = obj
|
||||
title = obj.get(CONF_OBJECT_NAME, entry.title)
|
||||
hass.config_entries.async_update_entry(entry, data=new_data, title=title)
|
||||
if device_link_changed:
|
||||
hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/object/delete",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_delete_object(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Delete a maintenance object and all its tasks."""
|
||||
entry = _load_object_entry(hass, connection, msg)
|
||||
if entry is None:
|
||||
return
|
||||
|
||||
await hass.config_entries.async_remove(entry.entry_id)
|
||||
cleanup_group_refs(hass, entry_id=entry.entry_id)
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/object/duplicate",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_duplicate_object(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Clone an object and all its tasks as a fresh, un-started copy.
|
||||
|
||||
A new config entry named "… (copy)" carries over the object's details and
|
||||
every task's configuration, but nothing device- or history-specific: the
|
||||
serial number is dropped, and each task starts clean (no history /
|
||||
last_performed, its own new id, and no unique entity_slug / NFC tag). Ideal
|
||||
for fleets of near-identical assets (hotel rooms, identical pumps).
|
||||
"""
|
||||
from copy import deepcopy
|
||||
|
||||
entry = _load_object_entry(hass, connection, msg)
|
||||
if entry is None:
|
||||
return
|
||||
|
||||
src_obj = entry.data.get(CONF_OBJECT, {})
|
||||
new_obj = deepcopy(dict(src_obj))
|
||||
new_obj["id"] = uuid4().hex
|
||||
base_name = str(src_obj.get(CONF_OBJECT_NAME, "")).strip() or "Object"
|
||||
new_obj[CONF_OBJECT_NAME] = f"{base_name} (copy)"[:MAX_NAME_LENGTH]
|
||||
# Serial number identifies one physical unit — never duplicate it.
|
||||
new_obj[CONF_OBJECT_SERIAL_NUMBER] = None
|
||||
new_obj["task_ids"] = []
|
||||
new_obj.pop("archived_at", None)
|
||||
|
||||
new_tasks: dict[str, Any] = {}
|
||||
for src_task in entry.data.get(CONF_TASKS, {}).values():
|
||||
task = deepcopy(dict(src_task))
|
||||
task_id = uuid4().hex
|
||||
task["id"] = task_id
|
||||
task["object_id"] = new_obj["id"]
|
||||
for key in (
|
||||
"entity_slug",
|
||||
"nfc_tag_id",
|
||||
"history",
|
||||
"last_performed",
|
||||
"last_planned_due",
|
||||
"adaptive_config",
|
||||
"archived_at",
|
||||
"archived_reason",
|
||||
):
|
||||
task.pop(key, None)
|
||||
if isinstance(task.get("trigger_config"), dict):
|
||||
task["trigger_config"].pop("_trigger_state", None)
|
||||
new_tasks[task_id] = task
|
||||
new_obj["task_ids"].append(task_id)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": "websocket"},
|
||||
data={CONF_OBJECT: new_obj, CONF_TASKS: new_tasks},
|
||||
)
|
||||
if result["type"] != "create_entry":
|
||||
connection.send_error(msg["id"], "duplicate_failed", result.get("reason", "unknown"))
|
||||
return
|
||||
connection.send_result(msg["id"], {"entry_id": result["result"].entry_id})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/object/from_template",
|
||||
vol.Required("template_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Optional("name"): vol.Any(vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)), None),
|
||||
# v2.21.1: the caller's UI language — created object/task names are
|
||||
# localized (falls back to the server language).
|
||||
vol.Optional("language"): vol.All(str, vol.Length(max=10)),
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_create_from_template(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Create an object (with its tasks) from a predefined template.
|
||||
|
||||
Surfaces the config-flow template gallery in the panel: builds the object +
|
||||
its tasks from the template and routes them through the same websocket flow
|
||||
step used elsewhere (cap + normalize + create).
|
||||
"""
|
||||
from uuid import uuid4
|
||||
|
||||
from ..helpers.i18n import normalize_language
|
||||
from ..templates import get_template_by_id, localize_template_text
|
||||
|
||||
template = get_template_by_id(msg["template_id"])
|
||||
if template is None:
|
||||
connection.send_error(msg["id"], "not_found", "Template not found")
|
||||
return
|
||||
|
||||
lang = (msg.get("language") or normalize_language(hass))[:2].lower()
|
||||
default_name = localize_template_text(template.name, lang) or template.name
|
||||
name = (msg.get("name") or default_name).strip() or default_name
|
||||
object_id = uuid4().hex
|
||||
new_obj: dict[str, Any] = {
|
||||
"id": object_id,
|
||||
CONF_OBJECT_NAME: name[:MAX_NAME_LENGTH],
|
||||
"task_ids": [],
|
||||
}
|
||||
new_tasks: dict[str, Any] = {}
|
||||
for tt in template.tasks:
|
||||
task_id = uuid4().hex
|
||||
task: dict[str, Any] = {
|
||||
"id": task_id,
|
||||
"object_id": object_id,
|
||||
"name": localize_template_text(tt.name, lang),
|
||||
"type": tt.type,
|
||||
"enabled": True,
|
||||
"schedule_type": tt.schedule_type,
|
||||
"warning_days": tt.warning_days,
|
||||
}
|
||||
if tt.interval_days is not None:
|
||||
task["interval_days"] = tt.interval_days
|
||||
if tt.notes:
|
||||
task["notes"] = localize_template_text(tt.notes, lang)
|
||||
new_tasks[task_id] = task
|
||||
new_obj["task_ids"].append(task_id)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": "websocket"},
|
||||
data={CONF_OBJECT: new_obj, CONF_TASKS: new_tasks},
|
||||
)
|
||||
if result["type"] != "create_entry":
|
||||
connection.send_error(msg["id"], "create_failed", result.get("reason", "unknown"))
|
||||
return
|
||||
connection.send_result(msg["id"], {"entry_id": result["result"].entry_id})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/object/archive",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_archive_object(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Archive an object and cascade to its active tasks.
|
||||
|
||||
Each currently-active task is archived with reason OBJECT, so a later object
|
||||
unarchive restores exactly those. A task already archived (manually/auto)
|
||||
keeps its own reason and is left untouched by the cascade.
|
||||
"""
|
||||
entry = _load_object_entry(hass, connection, msg)
|
||||
if entry is None:
|
||||
return
|
||||
|
||||
obj = dict(entry.data.get(CONF_OBJECT, {}))
|
||||
if obj.get("archived_at") is not None:
|
||||
connection.send_error(msg["id"], "already_archived", "Object already archived")
|
||||
return
|
||||
|
||||
now_iso = dt_util.now().isoformat()
|
||||
new_data = _archived_entry_data(entry.data, now_iso)
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
|
||||
# Reload so the object's tasks' triggers tear down and entities go inert.
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
connection.send_result(msg["id"], {"success": True, "archived_at": now_iso})
|
||||
|
||||
|
||||
def _archived_entry_data(entry_data: Any, now_iso: str) -> dict[str, Any]:
|
||||
"""New entry data with the object archived and active tasks cascaded.
|
||||
|
||||
Shared by ``object/archive`` and the replace flow (which retires the
|
||||
predecessor with exactly the same semantics).
|
||||
"""
|
||||
obj = dict(entry_data.get(CONF_OBJECT, {}))
|
||||
obj["archived_at"] = now_iso
|
||||
# Archiving supersedes a seasonal pause — don't leave both markers.
|
||||
obj.pop("paused_at", None)
|
||||
obj.pop("paused_until", None)
|
||||
|
||||
new_tasks: dict[str, Any] = {}
|
||||
for tid, td in dict(entry_data.get(CONF_TASKS, {})).items():
|
||||
td = dict(td)
|
||||
if td.get("archived_at") is None: # cascade only to active tasks
|
||||
td["archived_at"] = now_iso
|
||||
td["archived_reason"] = ARCHIVE_REASON_OBJECT
|
||||
new_tasks[tid] = td
|
||||
|
||||
new_data = dict(entry_data)
|
||||
new_data[CONF_OBJECT] = obj
|
||||
new_data[CONF_TASKS] = new_tasks
|
||||
return new_data
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/object/unarchive",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_unarchive_object(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Unarchive an object and un-cascade the tasks it had archived.
|
||||
|
||||
Only tasks archived BY this object (reason OBJECT) are restored; recurring
|
||||
ones get a fresh cycle (D2). Tasks archived manually or auto-archived keep
|
||||
their archived state — they were retired independently.
|
||||
"""
|
||||
entry = _load_object_entry(hass, connection, msg)
|
||||
if entry is None:
|
||||
return
|
||||
|
||||
obj = dict(entry.data.get(CONF_OBJECT, {}))
|
||||
if obj.get("archived_at") is None:
|
||||
connection.send_error(msg["id"], "not_archived", "Object is not archived")
|
||||
return
|
||||
obj.pop("archived_at", None)
|
||||
|
||||
rd = _get_runtime_data(hass, entry.entry_id)
|
||||
store = getattr(rd, "store", None) if rd else None
|
||||
today_iso = dt_util.now().date().isoformat()
|
||||
|
||||
tasks_data = dict(entry.data.get(CONF_TASKS, {}))
|
||||
new_tasks: dict[str, Any] = {}
|
||||
for tid, td in tasks_data.items():
|
||||
td = dict(td)
|
||||
if td.get("archived_reason") == ARCHIVE_REASON_OBJECT:
|
||||
td.pop("archived_at", None)
|
||||
td.pop("archived_reason", None)
|
||||
# Fresh cycle for recurring tasks (D2); last_performed is dynamic →
|
||||
# Store when present, else the static dict (legacy).
|
||||
if _is_recurring_schedule(td):
|
||||
if store is not None:
|
||||
store.set_last_performed(tid, today_iso)
|
||||
state = store._ensure_task(tid)
|
||||
state.pop("last_planned_due", None)
|
||||
else:
|
||||
td["last_performed"] = today_iso
|
||||
td.pop("last_planned_due", None)
|
||||
new_tasks[tid] = td
|
||||
|
||||
new_data = dict(entry.data)
|
||||
new_data[CONF_OBJECT] = obj
|
||||
new_data[CONF_TASKS] = new_tasks
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
if store is not None:
|
||||
await store.async_save()
|
||||
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/object/pause",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Optional("until"): vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None),
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_pause_object(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Seasonally pause an object (journey N3).
|
||||
|
||||
Tasks stay visible but read status ``paused``: schedules freeze, triggers
|
||||
tear down, nothing notifies. ``until`` (ISO date, optional) auto-resumes
|
||||
on that day via the coordinator; without it the pause holds until an
|
||||
explicit ``object/resume``.
|
||||
"""
|
||||
from datetime import date as date_cls
|
||||
|
||||
entry = _load_object_entry(hass, connection, msg)
|
||||
if entry is None:
|
||||
return
|
||||
|
||||
obj = dict(entry.data.get(CONF_OBJECT, {}))
|
||||
if obj.get("archived_at") is not None:
|
||||
connection.send_error(msg["id"], "archived", "An archived object cannot be paused")
|
||||
return
|
||||
if obj.get("paused_at") is not None:
|
||||
connection.send_error(msg["id"], "already_paused", "Object already paused")
|
||||
return
|
||||
|
||||
until = msg.get("until")
|
||||
if until:
|
||||
try:
|
||||
until_date = date_cls.fromisoformat(until)
|
||||
except ValueError:
|
||||
connection.send_error(msg["id"], "invalid_date", "Invalid until format (expected YYYY-MM-DD)")
|
||||
return
|
||||
if until_date <= dt_util.now().date():
|
||||
connection.send_error(msg["id"], "invalid_date", "until must be a future date")
|
||||
return
|
||||
|
||||
now_iso = dt_util.now().isoformat()
|
||||
obj["paused_at"] = now_iso
|
||||
obj["paused_until"] = until or None
|
||||
|
||||
new_data = dict(entry.data)
|
||||
new_data[CONF_OBJECT] = obj
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
|
||||
# Reload so triggers tear down and every entity repaints as paused.
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{"success": True, "paused_at": now_iso, "paused_until": until or None},
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/object/resume",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_resume_object(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""End a seasonal pause: schedules re-anchor to a fresh cycle from today.
|
||||
|
||||
Same core as the coordinator's ``paused_until`` auto-resume — the pool
|
||||
pump comes back with a clean slate, not five months overdue.
|
||||
"""
|
||||
from ..helpers.pause import build_resumed_entry_data
|
||||
|
||||
entry = _load_object_entry(hass, connection, msg)
|
||||
if entry is None:
|
||||
return
|
||||
|
||||
obj = entry.data.get(CONF_OBJECT, {})
|
||||
if obj.get("paused_at") is None:
|
||||
connection.send_error(msg["id"], "not_paused", "Object is not paused")
|
||||
return
|
||||
|
||||
rd = _get_runtime_data(hass, entry.entry_id)
|
||||
store = getattr(rd, "store", None) if rd else None
|
||||
new_data = build_resumed_entry_data(dict(entry.data), store, dt_util.now().date().isoformat())
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
if store is not None:
|
||||
await store.async_save()
|
||||
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/object/replace",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Optional("name"): vol.Any(vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)), None),
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_replace_object(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Replace a worn-out object with a successor (journey N1).
|
||||
|
||||
The predecessor is archived in place — its full history, costs and
|
||||
documents remain browsable, marked with ``replaced_by_entry_id``. The
|
||||
successor starts as a pre-filled fresh unit: same task configuration
|
||||
(fresh ids, no history), the documents carried over (manuals usually
|
||||
outlive the individual machine; blobs are refcounted, not copied), the
|
||||
installation date set to today, and serial number / warranty cleared —
|
||||
those belong to the specific old unit.
|
||||
"""
|
||||
from copy import deepcopy
|
||||
|
||||
entry = _load_object_entry(hass, connection, msg)
|
||||
if entry is None:
|
||||
return
|
||||
|
||||
src_obj = entry.data.get(CONF_OBJECT, {})
|
||||
if src_obj.get("archived_at") is not None:
|
||||
connection.send_error(msg["id"], "archived", "An archived object cannot be replaced")
|
||||
return
|
||||
|
||||
name = (msg.get("name") or "").strip() or str(src_obj.get(CONF_OBJECT_NAME, "")).strip() or "Object"
|
||||
|
||||
new_obj = deepcopy(dict(src_obj))
|
||||
new_obj["id"] = uuid4().hex
|
||||
new_obj[CONF_OBJECT_NAME] = name[:MAX_NAME_LENGTH]
|
||||
# Unit-specific identity does not transfer to the new machine.
|
||||
new_obj[CONF_OBJECT_SERIAL_NUMBER] = None
|
||||
new_obj[CONF_OBJECT_WARRANTY_EXPIRY] = None
|
||||
new_obj[CONF_OBJECT_INSTALLATION_DATE] = dt_util.now().date().isoformat()
|
||||
new_obj["task_ids"] = []
|
||||
for key in ("archived_at", "paused_at", "paused_until", "replaced_by_entry_id"):
|
||||
new_obj.pop(key, None)
|
||||
new_obj["predecessor_entry_id"] = entry.entry_id
|
||||
|
||||
new_tasks: dict[str, Any] = {}
|
||||
for src_task in entry.data.get(CONF_TASKS, {}).values():
|
||||
task = deepcopy(dict(src_task))
|
||||
task_id = uuid4().hex
|
||||
task["id"] = task_id
|
||||
task["object_id"] = new_obj["id"]
|
||||
for key in (
|
||||
"entity_slug",
|
||||
"nfc_tag_id",
|
||||
"history",
|
||||
"last_performed",
|
||||
"last_planned_due",
|
||||
"adaptive_config",
|
||||
"archived_at",
|
||||
"archived_reason",
|
||||
):
|
||||
task.pop(key, None)
|
||||
if isinstance(task.get("trigger_config"), dict):
|
||||
task["trigger_config"].pop("_trigger_state", None)
|
||||
new_tasks[task_id] = task
|
||||
new_obj["task_ids"].append(task_id)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": "websocket"},
|
||||
data={CONF_OBJECT: new_obj, CONF_TASKS: new_tasks},
|
||||
)
|
||||
if result["type"] != "create_entry":
|
||||
connection.send_error(msg["id"], "replace_failed", result.get("reason", "unknown"))
|
||||
return
|
||||
new_entry_id: str = result["result"].entry_id
|
||||
|
||||
# Carry the document library over — manuals outlive the machine. Blob
|
||||
# refcounts increase; nothing is copied on disk.
|
||||
from .. import DOCUMENT_STORE_KEY
|
||||
from . import object_id_for_entry
|
||||
|
||||
doc_store = hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY)
|
||||
if doc_store is not None:
|
||||
src_docs = doc_store.for_object(object_id_for_entry(entry))
|
||||
if src_docs:
|
||||
await doc_store.async_import_documents(new_obj["id"], src_docs)
|
||||
|
||||
# Retire the predecessor (archive cascade) with the successor pointer.
|
||||
now_iso = dt_util.now().isoformat()
|
||||
retired = _archived_entry_data(entry.data, now_iso)
|
||||
retired_obj = dict(retired[CONF_OBJECT])
|
||||
retired_obj["replaced_by_entry_id"] = new_entry_id
|
||||
retired[CONF_OBJECT] = retired_obj
|
||||
hass.config_entries.async_update_entry(entry, data=retired)
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
connection.send_result(msg["id"], {"entry_id": new_entry_id})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/entity/attributes",
|
||||
vol.Required("entity_id"): vol.All(str, vol.Length(max=MAX_ENTITY_ID_LENGTH)),
|
||||
}
|
||||
)
|
||||
@callback
|
||||
def ws_entity_attributes(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return relevant attributes for an entity, combining domain mapping with live state.
|
||||
|
||||
Used by the frontend trigger setup to show a dropdown of suitable attributes
|
||||
instead of a free text field.
|
||||
"""
|
||||
from ..helpers.entity_attributes import get_entity_attributes
|
||||
|
||||
result = get_entity_attributes(hass, msg["entity_id"])
|
||||
connection.send_result(msg["id"], result)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""WebSocket handler for listing HA NFC tags."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from ..const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_TAG_DOMAIN = "tag"
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/tags/list"})
|
||||
@websocket_api.async_response
|
||||
async def ws_list_tags(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return list of registered NFC tags from HA tag registry."""
|
||||
tags: list[dict[str, str]] = []
|
||||
|
||||
tag_storage = hass.data.get(_TAG_DOMAIN)
|
||||
if tag_storage is not None:
|
||||
ent_reg = er.async_get(hass)
|
||||
try:
|
||||
items = tag_storage.async_items()
|
||||
for item in items:
|
||||
tag_id = item.get("id", "") if isinstance(item, dict) else getattr(item, "id", "")
|
||||
tag_name = item.get("name", "") if isinstance(item, dict) else getattr(item, "name", "")
|
||||
if not tag_name and tag_id:
|
||||
# HA stores tag NAMES in the entity registry, not the tag
|
||||
# store (stripped on save since the tag→entity refactor).
|
||||
# Freshly created tags still carry the name in memory —
|
||||
# which is why the dropdown looked fine "until I restart,
|
||||
# then they only show up as UUIDs" (forum report). Resolve
|
||||
# exactly like HA's own tag/list handler does.
|
||||
entity_id = ent_reg.async_get_entity_id(_TAG_DOMAIN, _TAG_DOMAIN, tag_id)
|
||||
if entity_id and (entity := ent_reg.async_get(entity_id)):
|
||||
tag_name = entity.name or entity.original_name or ""
|
||||
tags.append({"id": tag_id, "name": tag_name or tag_id})
|
||||
except Exception: # noqa: BLE001 - HA tag storage internals; an empty list lets the picker still render
|
||||
_LOGGER.warning("Failed to read NFC tag registry", exc_info=True)
|
||||
|
||||
connection.send_result(msg["id"], {"tags": tags})
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Backward-compat re-export shim for the task WS handlers.
|
||||
|
||||
The handlers were split (module modularization refactor) across tasks_validation
|
||||
/ tasks_persist / tasks_crud / tasks_lifecycle / tasks_actions / tasks_history.
|
||||
This shim keeps the historical `...websocket.tasks import X` import path stable
|
||||
for the test suite, the websocket command registration, and the sibling
|
||||
production modules. ``__all__`` makes the re-exports explicit for mypy --strict.
|
||||
"""
|
||||
|
||||
from .tasks_actions import (
|
||||
ws_complete_task,
|
||||
ws_quick_complete_task,
|
||||
ws_reset_task,
|
||||
ws_skip_task,
|
||||
ws_snooze_task,
|
||||
)
|
||||
from .tasks_crud import (
|
||||
async_delete_task,
|
||||
ws_create_task,
|
||||
ws_delete_task,
|
||||
ws_duplicate_task,
|
||||
ws_update_task,
|
||||
)
|
||||
from .tasks_history import ws_update_history_entry
|
||||
from .tasks_lifecycle import (
|
||||
_is_recurring_schedule,
|
||||
ws_archive_task,
|
||||
ws_list_tasks,
|
||||
ws_unarchive_task,
|
||||
)
|
||||
from .tasks_persist import (
|
||||
async_create_task_simple,
|
||||
async_persist_task,
|
||||
)
|
||||
from .tasks_validation import (
|
||||
_SAFE_URL_SCHEMES,
|
||||
_TRIGGER_ALLOWED_KEYS,
|
||||
_TRIGGER_REQUIRED_FIELDS,
|
||||
_VALID_TRIGGER_TYPES,
|
||||
_check_nfc_tag_duplicate,
|
||||
_is_safe_url,
|
||||
_validate_compound_trigger,
|
||||
_validate_trigger_config,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"_SAFE_URL_SCHEMES",
|
||||
"_TRIGGER_ALLOWED_KEYS",
|
||||
"_TRIGGER_REQUIRED_FIELDS",
|
||||
"_VALID_TRIGGER_TYPES",
|
||||
"_check_nfc_tag_duplicate",
|
||||
"_is_recurring_schedule",
|
||||
"_is_safe_url",
|
||||
"_validate_compound_trigger",
|
||||
"_validate_trigger_config",
|
||||
"async_create_task_simple",
|
||||
"async_delete_task",
|
||||
"async_persist_task",
|
||||
"ws_archive_task",
|
||||
"ws_complete_task",
|
||||
"ws_create_task",
|
||||
"ws_delete_task",
|
||||
"ws_duplicate_task",
|
||||
"ws_list_tasks",
|
||||
"ws_quick_complete_task",
|
||||
"ws_reset_task",
|
||||
"ws_skip_task",
|
||||
"ws_snooze_task",
|
||||
"ws_unarchive_task",
|
||||
"ws_update_history_entry",
|
||||
"ws_update_task",
|
||||
]
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Task action WS handlers: complete / quick_complete / skip / reset."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..const import (
|
||||
CONF_TASKS,
|
||||
MAX_CHECKLIST_ITEM_LENGTH,
|
||||
MAX_CHECKLIST_ITEMS,
|
||||
MAX_DATE_LENGTH,
|
||||
MAX_ID_LENGTH,
|
||||
MAX_TEXT_LENGTH,
|
||||
)
|
||||
from ..models.maintenance_task import MaintenanceTask
|
||||
from . import (
|
||||
_get_runtime_data,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task Actions (Complete / Skip / Reset)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _completion_blocked(rd: Any, task_id: str) -> bool:
|
||||
"""True iff the task's completion window forbids completing it right now.
|
||||
|
||||
Uses the coordinator's merged (live) task data so ``last_performed`` /
|
||||
``next_due`` reflect the Store, not the stale config entry.
|
||||
"""
|
||||
coordinator = getattr(rd, "coordinator", None)
|
||||
if coordinator is None:
|
||||
return False
|
||||
merged = coordinator._get_merged_tasks_data()
|
||||
td = merged.get(task_id)
|
||||
if not td:
|
||||
return False
|
||||
return not MaintenanceTask.from_dict(td).can_complete_now
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/task/complete",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Optional("notes"): vol.Any(vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)), None),
|
||||
vol.Optional("cost"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0, max=1_000_000)), None),
|
||||
vol.Optional("duration"): vol.Any(vol.All(vol.Coerce(int), vol.Range(min=0, max=525_600)), None),
|
||||
# Restrict checklist_state to {string-key (≤500): bool, ...} with
|
||||
# a hard cap on entries. Without this, attackers (or bad clients)
|
||||
# could inflate the per-task history with arbitrarily large dicts.
|
||||
vol.Optional("checklist_state"): vol.Any(
|
||||
vol.All(
|
||||
{vol.All(str, vol.Length(max=MAX_CHECKLIST_ITEM_LENGTH)): bool},
|
||||
vol.Length(max=MAX_CHECKLIST_ITEMS),
|
||||
),
|
||||
None,
|
||||
),
|
||||
vol.Optional("feedback"): vol.Any(vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)), None),
|
||||
# Optional completion photo: the doc_id of an already-uploaded image
|
||||
# (via the document upload endpoint, tagged "photo").
|
||||
vol.Optional("photo_doc_id"): vol.Any(vol.All(str, vol.Length(max=MAX_ID_LENGTH)), None),
|
||||
# Meter readings (v2.20, #83): the recorded value for `reading` tasks.
|
||||
# Wide numeric bounds — meters count high, temperatures go negative.
|
||||
vol.Optional("reading_value"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-1e12, max=1e12)), None),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_complete_task(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Mark a task as completed."""
|
||||
rd = _get_runtime_data(hass, msg["entry_id"])
|
||||
if rd is None or rd.coordinator is None:
|
||||
connection.send_error(msg["id"], "not_found", "Coordinator not found")
|
||||
return
|
||||
|
||||
entry = hass.config_entries.async_get_entry(msg["entry_id"])
|
||||
if entry is None or msg["task_id"] not in entry.data.get(CONF_TASKS, {}):
|
||||
connection.send_error(msg["id"], "not_found", "Task not found")
|
||||
return
|
||||
|
||||
if _completion_blocked(rd, msg["task_id"]):
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
"too_early",
|
||||
"Task can only be completed closer to its due date",
|
||||
)
|
||||
return
|
||||
|
||||
await rd.coordinator.complete_maintenance(
|
||||
task_id=msg["task_id"],
|
||||
notes=msg.get("notes"),
|
||||
cost=msg.get("cost"),
|
||||
duration=msg.get("duration"),
|
||||
checklist_state=msg.get("checklist_state"),
|
||||
feedback=msg.get("feedback"),
|
||||
photo_doc_id=msg.get("photo_doc_id"),
|
||||
reading_value=msg.get("reading_value"),
|
||||
)
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
|
||||
|
||||
# v1.3.0: One-tap completion using values pre-configured on the task.
|
||||
# Used by the "quick_complete" QR scan path. Falls back with `no_defaults`
|
||||
# error when the task has no quick_complete_defaults — frontend then
|
||||
# routes the user to the normal complete dialog.
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/task/quick_complete",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_quick_complete_task(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Complete a task using its pre-configured `quick_complete_defaults`."""
|
||||
rd = _get_runtime_data(hass, msg["entry_id"])
|
||||
if rd is None or rd.coordinator is None:
|
||||
connection.send_error(msg["id"], "not_found", "Coordinator not found")
|
||||
return
|
||||
|
||||
entry = hass.config_entries.async_get_entry(msg["entry_id"])
|
||||
if entry is None:
|
||||
connection.send_error(msg["id"], "not_found", "Object not found")
|
||||
return
|
||||
task = entry.data.get(CONF_TASKS, {}).get(msg["task_id"])
|
||||
if not task:
|
||||
connection.send_error(msg["id"], "not_found", "Task not found")
|
||||
return
|
||||
|
||||
if _completion_blocked(rd, msg["task_id"]):
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
"too_early",
|
||||
"Task can only be completed closer to its due date",
|
||||
)
|
||||
return
|
||||
|
||||
defaults = task.get("quick_complete_defaults") or {}
|
||||
if not isinstance(defaults, dict) or not defaults:
|
||||
# Frontend fallback: open the normal complete dialog so the user
|
||||
# is never stuck staring at a useless QR scan.
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
"no_defaults",
|
||||
"Task has no quick_complete_defaults; open complete dialog instead",
|
||||
)
|
||||
return
|
||||
|
||||
await rd.coordinator.complete_maintenance(
|
||||
task_id=msg["task_id"],
|
||||
notes=defaults.get("notes"),
|
||||
cost=defaults.get("cost"),
|
||||
duration=defaults.get("duration"),
|
||||
feedback=defaults.get("feedback"),
|
||||
)
|
||||
connection.send_result(msg["id"], {"success": True, "via": "quick"})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/task/skip",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Optional("reason"): vol.Any(vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)), None),
|
||||
# Record the skipped cycle as MISSED (was due, never done) rather than a
|
||||
# deliberate skip — clearer history + compliance views.
|
||||
vol.Optional("as_missed", default=False): bool,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_skip_task(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Skip the current maintenance cycle."""
|
||||
rd = _get_runtime_data(hass, msg["entry_id"])
|
||||
if rd is None or rd.coordinator is None:
|
||||
connection.send_error(msg["id"], "not_found", "Coordinator not found")
|
||||
return
|
||||
|
||||
entry = hass.config_entries.async_get_entry(msg["entry_id"])
|
||||
if entry is None or msg["task_id"] not in entry.data.get(CONF_TASKS, {}):
|
||||
connection.send_error(msg["id"], "not_found", "Task not found")
|
||||
return
|
||||
|
||||
await rd.coordinator.skip_maintenance(
|
||||
task_id=msg["task_id"],
|
||||
reason=msg.get("reason"),
|
||||
as_missed=msg.get("as_missed", False),
|
||||
)
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/task/reset",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Optional("date"): vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_reset_task(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Reset the last performed date."""
|
||||
from datetime import date as date_cls
|
||||
|
||||
rd = _get_runtime_data(hass, msg["entry_id"])
|
||||
if rd is None or rd.coordinator is None:
|
||||
connection.send_error(msg["id"], "not_found", "Coordinator not found")
|
||||
return
|
||||
|
||||
entry = hass.config_entries.async_get_entry(msg["entry_id"])
|
||||
if entry is None or msg["task_id"] not in entry.data.get(CONF_TASKS, {}):
|
||||
connection.send_error(msg["id"], "not_found", "Task not found")
|
||||
return
|
||||
|
||||
reset_date = None
|
||||
if msg.get("date"):
|
||||
try:
|
||||
reset_date = date_cls.fromisoformat(msg["date"])
|
||||
except ValueError:
|
||||
connection.send_error(msg["id"], "invalid_date", "Invalid date format")
|
||||
return
|
||||
|
||||
await rd.coordinator.reset_maintenance(
|
||||
task_id=msg["task_id"],
|
||||
date=reset_date,
|
||||
)
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/task/snooze",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_snooze_task(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Snooze a task's notifications for the configured snooze duration.
|
||||
|
||||
Surfaces the existing notification-action snooze on the panel. Suppresses
|
||||
due-soon/overdue/triggered reminders for ``snooze_duration_hours`` — it does
|
||||
not change the task's schedule or state.
|
||||
"""
|
||||
from .. import DOMAIN, NOTIFICATION_MANAGER_KEY
|
||||
|
||||
entry = hass.config_entries.async_get_entry(msg["entry_id"])
|
||||
if entry is None or msg["task_id"] not in entry.data.get(CONF_TASKS, {}):
|
||||
connection.send_error(msg["id"], "not_found", "Task not found")
|
||||
return
|
||||
|
||||
nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY)
|
||||
if nm is None:
|
||||
connection.send_error(msg["id"], "unavailable", "Notifications not configured")
|
||||
return
|
||||
nm.snooze_task(msg["entry_id"], msg["task_id"])
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
@@ -0,0 +1,682 @@
|
||||
"""Task create / update / delete / duplicate WS handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from ..const import (
|
||||
CONF_OBJECT,
|
||||
CONF_TASKS,
|
||||
DEFAULT_WARNING_DAYS,
|
||||
DOMAIN,
|
||||
FLAT_SCHEDULE_TYPES,
|
||||
GLOBAL_UNIQUE_ID,
|
||||
MAX_ASSIGNEE_POOL,
|
||||
MAX_CHECKLIST_ITEM_LENGTH,
|
||||
MAX_CHECKLIST_ITEMS,
|
||||
MAX_DATE_LENGTH,
|
||||
MAX_ENTITY_SLUG_LENGTH,
|
||||
MAX_ICON_LENGTH,
|
||||
MAX_ID_LENGTH,
|
||||
MAX_LABEL_LENGTH,
|
||||
MAX_LABELS,
|
||||
MAX_META_LENGTH,
|
||||
MAX_NAME_LENGTH,
|
||||
MAX_TEXT_LENGTH,
|
||||
MAX_TYPE_LENGTH,
|
||||
MAX_URL_LENGTH,
|
||||
HistoryEntryType,
|
||||
)
|
||||
from ..helpers.dates import INTERVAL_UNITS
|
||||
from ..helpers.permissions import require_write
|
||||
from ..helpers.schedule import (
|
||||
FLAT_RECURRENCE_KEYS,
|
||||
Schedule,
|
||||
normalize_task_storage,
|
||||
)
|
||||
from ..helpers.task_fields import (
|
||||
EARLIEST_COMPLETION_RANGE,
|
||||
INTERVAL_ANCHORS,
|
||||
INTERVAL_DAYS_RANGE,
|
||||
ROTATION_STRATEGY_VALUES,
|
||||
TASK_PRIORITIES,
|
||||
WARNING_DAYS_RANGE,
|
||||
)
|
||||
from . import (
|
||||
_get_runtime_data,
|
||||
_load_object_entry,
|
||||
cleanup_group_refs,
|
||||
)
|
||||
from .tasks_persist import async_persist_task
|
||||
from .tasks_validation import (
|
||||
_check_nfc_tag_duplicate,
|
||||
_is_safe_url,
|
||||
_validate_trigger_config,
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/task/create",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Required("name"): vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)),
|
||||
vol.Optional("task_type", default="custom"): vol.All(str, vol.Length(max=MAX_TYPE_LENGTH)),
|
||||
vol.Optional("schedule_type", default="time_based"): vol.All(str, vol.Length(max=MAX_TYPE_LENGTH)),
|
||||
vol.Optional("interval_days"): vol.Any(
|
||||
vol.All(int, vol.Range(min=INTERVAL_DAYS_RANGE[0], max=INTERVAL_DAYS_RANGE[1])), None
|
||||
),
|
||||
vol.Optional("interval_unit", default="days"): vol.In(INTERVAL_UNITS),
|
||||
vol.Optional("due_date"): vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None),
|
||||
vol.Optional("interval_anchor", default="completion"): vol.In(INTERVAL_ANCHORS),
|
||||
# Nested recurrence (calendar kinds: weekdays / nth_weekday / day_of_month).
|
||||
# Validated/canonicalized in the handler via Schedule.from_dict.
|
||||
vol.Optional("schedule"): vol.Any(dict, None),
|
||||
vol.Optional("warning_days", default=DEFAULT_WARNING_DAYS): vol.All(
|
||||
int, vol.Range(min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1])
|
||||
),
|
||||
vol.Optional("earliest_completion_days"): vol.Any(
|
||||
vol.All(int, vol.Range(min=EARLIEST_COMPLETION_RANGE[0], max=EARLIEST_COMPLETION_RANGE[1])), None
|
||||
),
|
||||
vol.Optional("last_performed"): vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None),
|
||||
vol.Optional("trigger_config"): vol.Any(dict, None),
|
||||
vol.Optional("notes"): vol.Any(vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)), None),
|
||||
vol.Optional("documentation_url"): vol.Any(vol.All(str, vol.Length(max=MAX_URL_LENGTH)), None),
|
||||
vol.Optional("responsible_user_id"): vol.Any(vol.All(str, vol.Length(max=MAX_META_LENGTH)), None),
|
||||
vol.Optional("assignee_pool"): vol.Any(
|
||||
vol.All([vol.All(str, vol.Length(max=MAX_META_LENGTH))], vol.Length(max=MAX_ASSIGNEE_POOL)), None
|
||||
),
|
||||
vol.Optional("rotation_strategy"): vol.Any(vol.In(ROTATION_STRATEGY_VALUES), None),
|
||||
vol.Optional("entity_slug"): vol.Any(vol.All(str, vol.Length(max=MAX_ENTITY_SLUG_LENGTH)), None),
|
||||
vol.Optional("custom_icon"): vol.Any(vol.All(str, vol.Length(max=MAX_ICON_LENGTH)), None),
|
||||
vol.Optional("nfc_tag_id"): vol.Any(vol.All(str, vol.Length(max=256)), None),
|
||||
# v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...).
|
||||
vol.Optional("reading_unit"): vol.Any(vol.All(str, vol.Length(max=32)), None),
|
||||
vol.Optional("priority"): vol.In(TASK_PRIORITIES),
|
||||
vol.Optional("checklist"): vol.Any(
|
||||
vol.All([vol.All(str, vol.Length(max=MAX_CHECKLIST_ITEM_LENGTH))], vol.Length(max=MAX_CHECKLIST_ITEMS)), None
|
||||
),
|
||||
vol.Optional("labels"): vol.Any(
|
||||
vol.All([vol.All(str, vol.Length(max=MAX_LABEL_LENGTH))], vol.Length(max=MAX_LABELS)), 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$")),
|
||||
None,
|
||||
),
|
||||
# v1.3.0: per-task on_complete_action + quick_complete_defaults.
|
||||
# Both kept loose at the schema level (vol.Any(dict, None)); strict
|
||||
# field-by-field validation lives in helpers/sanitize.py so the
|
||||
# config-flow path (which doesn't go through this schema) gets
|
||||
# identical validation behaviour.
|
||||
vol.Optional("on_complete_action"): vol.Any(dict, None),
|
||||
vol.Optional("quick_complete_defaults"): vol.Any(dict, None),
|
||||
vol.Optional("enabled", default=True): bool,
|
||||
vol.Optional("dry_run", default=False): bool,
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_create_task(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Add a new task to an existing maintenance object."""
|
||||
entry = _load_object_entry(hass, connection, msg)
|
||||
if entry is None:
|
||||
return
|
||||
|
||||
task_id = uuid4().hex
|
||||
name = msg["name"].strip()
|
||||
if not name:
|
||||
connection.send_error(msg["id"], "invalid_input", "Name must not be empty")
|
||||
return
|
||||
|
||||
task_data: dict[str, Any] = {
|
||||
"id": task_id,
|
||||
"object_id": entry.data.get(CONF_OBJECT, {}).get("id", ""),
|
||||
"name": name,
|
||||
"type": msg.get("task_type", "custom"),
|
||||
"enabled": msg.get("enabled", True),
|
||||
"warning_days": msg.get("warning_days", DEFAULT_WARNING_DAYS),
|
||||
# Anchor for next_due fallback when last_performed is None (issue #30).
|
||||
# Use HA's timezone-aware "today" to match next_due computation.
|
||||
"created_at": dt_util.now().date().isoformat(),
|
||||
}
|
||||
|
||||
# Dynamic state (last_performed, history) for Store initialization
|
||||
initial_last_performed: str | None = None
|
||||
initial_history: list[dict[str, Any]] = []
|
||||
|
||||
# Recurrence: an explicit nested `schedule` (calendar kinds) takes
|
||||
# precedence; otherwise build from the flat v2.6.x fields.
|
||||
if msg.get("schedule"):
|
||||
task_data["schedule"] = Schedule.from_dict(msg["schedule"]).to_dict()
|
||||
else:
|
||||
task_data["schedule_type"] = msg.get("schedule_type", "time_based")
|
||||
if msg.get("interval_days") is not None:
|
||||
task_data["interval_days"] = msg["interval_days"]
|
||||
if msg.get("interval_unit", "days") != "days":
|
||||
task_data["interval_unit"] = msg["interval_unit"]
|
||||
if msg.get("due_date") is not None:
|
||||
task_data["due_date"] = msg["due_date"]
|
||||
if msg.get("interval_anchor", "completion") != "completion":
|
||||
task_data["interval_anchor"] = msg["interval_anchor"]
|
||||
if msg.get("last_performed") is not None:
|
||||
try:
|
||||
date.fromisoformat(msg["last_performed"])
|
||||
except (ValueError, TypeError):
|
||||
connection.send_error(msg["id"], "invalid_format", "last_performed must be a valid date (YYYY-MM-DD)")
|
||||
return
|
||||
initial_last_performed = msg["last_performed"]
|
||||
# Add initial history entry so times_performed reflects the value.
|
||||
# Use HA-TZ-aware midnight to keep interval_analyzer consistent.
|
||||
from datetime import datetime, time
|
||||
|
||||
lp_date = date.fromisoformat(msg["last_performed"])
|
||||
lp_dt = datetime.combine(lp_date, time.min, tzinfo=dt_util.DEFAULT_TIME_ZONE)
|
||||
initial_history.append(
|
||||
{
|
||||
"timestamp": lp_dt.isoformat(),
|
||||
"type": HistoryEntryType.COMPLETED,
|
||||
"notes": "Initial value set during task creation",
|
||||
}
|
||||
)
|
||||
trigger_config = msg.get("trigger_config")
|
||||
tc_errors: list[str] = []
|
||||
tc_warnings: list[str] = []
|
||||
if trigger_config is not None:
|
||||
tc_errors, tc_warnings = _validate_trigger_config(hass, trigger_config)
|
||||
if tc_errors:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
"invalid_trigger_config",
|
||||
"; ".join(tc_errors),
|
||||
)
|
||||
return
|
||||
task_data["trigger_config"] = trigger_config
|
||||
if msg.get("notes") is not None:
|
||||
task_data["notes"] = msg["notes"]
|
||||
if msg.get("documentation_url") is not None:
|
||||
if not _is_safe_url(msg["documentation_url"]):
|
||||
connection.send_error(msg["id"], "invalid_url", "Only http/https URLs are allowed")
|
||||
return
|
||||
task_data["documentation_url"] = msg["documentation_url"]
|
||||
if msg.get("responsible_user_id") is not None:
|
||||
task_data["responsible_user_id"] = msg["responsible_user_id"]
|
||||
if msg.get("earliest_completion_days") is not None:
|
||||
task_data["earliest_completion_days"] = msg["earliest_completion_days"]
|
||||
if msg.get("assignee_pool"):
|
||||
from ..helpers.sanitize import sanitize_assignee_pool
|
||||
|
||||
task_data["assignee_pool"] = sanitize_assignee_pool(msg["assignee_pool"])
|
||||
if msg.get("rotation_strategy"):
|
||||
task_data["rotation_strategy"] = msg["rotation_strategy"]
|
||||
if msg.get("entity_slug") is not None:
|
||||
slug = msg["entity_slug"]
|
||||
if not re.fullmatch(r"[a-z0-9_]+", slug):
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
"invalid_entity_slug",
|
||||
"entity_slug must match [a-z0-9_]+ (lowercase, digits, underscores only)",
|
||||
)
|
||||
return
|
||||
task_data["entity_slug"] = slug
|
||||
if msg.get("custom_icon") is not None:
|
||||
task_data["custom_icon"] = msg["custom_icon"]
|
||||
if msg.get("priority") is not None:
|
||||
task_data["priority"] = msg["priority"]
|
||||
if msg.get("nfc_tag_id") is not None:
|
||||
nfc_val = (msg["nfc_tag_id"] or "").strip() or None # normalise ""/ whitespace → None
|
||||
task_data["nfc_tag_id"] = nfc_val
|
||||
if nfc_val:
|
||||
nfc_warn = _check_nfc_tag_duplicate(hass, nfc_val)
|
||||
if nfc_warn:
|
||||
tc_warnings.append(nfc_warn)
|
||||
# v2.20 (#83): unit for `reading`-type tasks.
|
||||
if msg.get("reading_unit") is not None:
|
||||
task_data["reading_unit"] = (msg["reading_unit"] or "").strip() or None
|
||||
if msg.get("checklist"):
|
||||
task_data["checklist"] = msg["checklist"]
|
||||
if msg.get("labels"):
|
||||
from ..helpers.sanitize import sanitize_labels
|
||||
|
||||
task_data["labels"] = sanitize_labels(msg["labels"])
|
||||
if msg.get("schedule_time"):
|
||||
task_data["schedule_time"] = msg["schedule_time"]
|
||||
# v1.3.0: optional completion-action + quick-defaults. Strict shape
|
||||
# validated by sanitize.cap_action_field / cap_quick_complete_defaults_field
|
||||
# below — accepted loosely here, dropped if malformed.
|
||||
if msg.get("on_complete_action"):
|
||||
task_data["on_complete_action"] = msg["on_complete_action"]
|
||||
if msg.get("quick_complete_defaults"):
|
||||
task_data["quick_complete_defaults"] = msg["quick_complete_defaults"]
|
||||
from ..helpers.sanitize import cap_action_field, cap_quick_complete_defaults_field
|
||||
|
||||
cap_action_field(task_data)
|
||||
cap_quick_complete_defaults_field(task_data)
|
||||
|
||||
# Dry-run mode: validate only, do not persist
|
||||
if msg.get("dry_run"):
|
||||
result: dict[str, Any] = {"valid": True, "task_id": None}
|
||||
if tc_warnings:
|
||||
result["warnings"] = tc_warnings
|
||||
connection.send_result(msg["id"], result)
|
||||
return
|
||||
|
||||
await async_persist_task(
|
||||
hass,
|
||||
entry,
|
||||
task_data,
|
||||
last_performed=initial_last_performed,
|
||||
history=initial_history,
|
||||
)
|
||||
|
||||
result = {"task_id": task_id}
|
||||
if tc_warnings:
|
||||
result["warnings"] = tc_warnings
|
||||
connection.send_result(msg["id"], result)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/task/update",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Optional("name"): vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)),
|
||||
vol.Optional("task_type"): vol.All(str, vol.Length(max=MAX_TYPE_LENGTH)),
|
||||
vol.Optional("enabled"): bool,
|
||||
vol.Optional("schedule_type"): vol.All(str, vol.Length(max=MAX_TYPE_LENGTH)),
|
||||
vol.Optional("interval_days"): vol.Any(
|
||||
vol.All(int, vol.Range(min=INTERVAL_DAYS_RANGE[0], max=INTERVAL_DAYS_RANGE[1])), None
|
||||
),
|
||||
vol.Optional("interval_unit"): vol.In(INTERVAL_UNITS),
|
||||
vol.Optional("due_date"): vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None),
|
||||
vol.Optional("interval_anchor"): vol.In(INTERVAL_ANCHORS),
|
||||
# Nested recurrence (calendar kinds); see create schema.
|
||||
vol.Optional("schedule"): vol.Any(dict, None),
|
||||
vol.Optional("warning_days"): vol.All(int, vol.Range(min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1])),
|
||||
vol.Optional("earliest_completion_days"): vol.Any(
|
||||
vol.All(int, vol.Range(min=EARLIEST_COMPLETION_RANGE[0], max=EARLIEST_COMPLETION_RANGE[1])), None
|
||||
),
|
||||
vol.Optional("last_performed"): vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None),
|
||||
vol.Optional("trigger_config"): vol.Any(dict, None),
|
||||
vol.Optional("notes"): vol.Any(vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)), None),
|
||||
vol.Optional("documentation_url"): vol.Any(vol.All(str, vol.Length(max=MAX_URL_LENGTH)), None),
|
||||
vol.Optional("responsible_user_id"): vol.Any(vol.All(str, vol.Length(max=MAX_META_LENGTH)), None),
|
||||
vol.Optional("assignee_pool"): vol.Any(
|
||||
vol.All([vol.All(str, vol.Length(max=MAX_META_LENGTH))], vol.Length(max=MAX_ASSIGNEE_POOL)), None
|
||||
),
|
||||
vol.Optional("rotation_strategy"): vol.Any(vol.In(ROTATION_STRATEGY_VALUES), None),
|
||||
vol.Optional("entity_slug"): vol.Any(vol.All(str, vol.Length(max=MAX_ENTITY_SLUG_LENGTH)), None),
|
||||
vol.Optional("custom_icon"): vol.Any(vol.All(str, vol.Length(max=MAX_ICON_LENGTH)), None),
|
||||
vol.Optional("nfc_tag_id"): vol.Any(vol.All(str, vol.Length(max=256)), None),
|
||||
# v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...).
|
||||
vol.Optional("reading_unit"): vol.Any(vol.All(str, vol.Length(max=32)), None),
|
||||
vol.Optional("priority"): vol.In(TASK_PRIORITIES),
|
||||
vol.Optional("checklist"): vol.Any(
|
||||
vol.All([vol.All(str, vol.Length(max=MAX_CHECKLIST_ITEM_LENGTH))], vol.Length(max=MAX_CHECKLIST_ITEMS)), None
|
||||
),
|
||||
vol.Optional("labels"): vol.Any(
|
||||
vol.All([vol.All(str, vol.Length(max=MAX_LABEL_LENGTH))], vol.Length(max=MAX_LABELS)), None
|
||||
),
|
||||
vol.Optional("schedule_time"): vol.Any(
|
||||
vol.All(str, vol.Match(r"^([01]\d|2[0-3]):[0-5]\d$")),
|
||||
None,
|
||||
),
|
||||
# v1.3.0: same loose schema as create. Sanitize layer enforces shape.
|
||||
vol.Optional("on_complete_action"): vol.Any(dict, None),
|
||||
vol.Optional("quick_complete_defaults"): vol.Any(dict, None),
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_update_task(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Update an existing task."""
|
||||
entry = _load_object_entry(hass, connection, msg)
|
||||
if entry is None:
|
||||
return
|
||||
|
||||
tasks_data = dict(entry.data.get(CONF_TASKS, {}))
|
||||
task_id = msg["task_id"]
|
||||
if task_id not in tasks_data:
|
||||
connection.send_error(msg["id"], "not_found", "Task not found")
|
||||
return
|
||||
|
||||
task = dict(tasks_data[task_id])
|
||||
|
||||
# Strip and validate name if provided
|
||||
if "name" in msg:
|
||||
msg["name"] = msg["name"].strip()
|
||||
if not msg["name"]:
|
||||
connection.send_error(msg["id"], "invalid_input", "Name must not be empty")
|
||||
return
|
||||
|
||||
# Validate trigger_config if provided
|
||||
tc_warnings: list[str] = []
|
||||
if "trigger_config" in msg and msg["trigger_config"] is not None:
|
||||
tc_errors, tc_warnings = _validate_trigger_config(hass, msg["trigger_config"])
|
||||
if tc_errors:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
"invalid_trigger_config",
|
||||
"; ".join(tc_errors),
|
||||
)
|
||||
return
|
||||
|
||||
# Validate entity_slug if provided
|
||||
if "entity_slug" in msg and msg["entity_slug"] is not None:
|
||||
slug = msg["entity_slug"]
|
||||
if not re.fullmatch(r"[a-z0-9_]+", slug):
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
"invalid_entity_slug",
|
||||
"entity_slug must match [a-z0-9_]+ (lowercase, digits, underscores only)",
|
||||
)
|
||||
return
|
||||
|
||||
# Normalise empty NFC tag to None and check uniqueness
|
||||
if "nfc_tag_id" in msg:
|
||||
msg["nfc_tag_id"] = (msg["nfc_tag_id"] or "").strip() or None
|
||||
if msg["nfc_tag_id"]:
|
||||
nfc_warn = _check_nfc_tag_duplicate(hass, msg["nfc_tag_id"], exclude_task_id=task_id)
|
||||
if nfc_warn:
|
||||
tc_warnings.append(nfc_warn)
|
||||
|
||||
# Validate last_performed date format if provided
|
||||
if "last_performed" in msg and msg["last_performed"] is not None:
|
||||
try:
|
||||
date.fromisoformat(msg["last_performed"])
|
||||
except (ValueError, TypeError):
|
||||
connection.send_error(msg["id"], "invalid_format", "last_performed must be a valid date (YYYY-MM-DD)")
|
||||
return
|
||||
|
||||
# Validate documentation_url if provided
|
||||
if "documentation_url" in msg and not _is_safe_url(msg["documentation_url"]):
|
||||
connection.send_error(msg["id"], "invalid_url", "Only http/https URLs are allowed")
|
||||
return
|
||||
|
||||
# Update provided fields. Wire key -> storage key. Almost all are identity;
|
||||
# the one rename is deliberate and load-bearing: the WS message envelope
|
||||
# reserves "type" for command routing ({"type": "maintenance_supporter/
|
||||
# task/update"}), so a task's own type must travel as "task_type" on the
|
||||
# wire and is stored as "type". Do NOT "simplify" this to "type": "type"
|
||||
# — it would collide with the routing key. The panel↔config-flow parity
|
||||
# test (test_parity_task_fields) encodes the same task_type->type alias.
|
||||
field_map = {
|
||||
"name": "name",
|
||||
"task_type": "type",
|
||||
"enabled": "enabled",
|
||||
"schedule_type": "schedule_type",
|
||||
"interval_days": "interval_days",
|
||||
"interval_unit": "interval_unit",
|
||||
"due_date": "due_date",
|
||||
"interval_anchor": "interval_anchor",
|
||||
"warning_days": "warning_days",
|
||||
"earliest_completion_days": "earliest_completion_days",
|
||||
"last_performed": "last_performed",
|
||||
"trigger_config": "trigger_config",
|
||||
"notes": "notes",
|
||||
"documentation_url": "documentation_url",
|
||||
"responsible_user_id": "responsible_user_id",
|
||||
"assignee_pool": "assignee_pool",
|
||||
"rotation_strategy": "rotation_strategy",
|
||||
"entity_slug": "entity_slug",
|
||||
"custom_icon": "custom_icon",
|
||||
"nfc_tag_id": "nfc_tag_id",
|
||||
"reading_unit": "reading_unit",
|
||||
"priority": "priority",
|
||||
"checklist": "checklist",
|
||||
"labels": "labels",
|
||||
"schedule_time": "schedule_time",
|
||||
# v1.3.0
|
||||
"on_complete_action": "on_complete_action",
|
||||
"quick_complete_defaults": "quick_complete_defaults",
|
||||
}
|
||||
for msg_key, data_key in field_map.items():
|
||||
if msg_key in msg:
|
||||
task[data_key] = msg[msg_key]
|
||||
|
||||
# Recurrence resolution: an explicit nested `schedule` wins (calendar kinds
|
||||
# and kind-switches). Otherwise rebuild from the flat view ONLY when a real
|
||||
# legacy recurrence signal is present — a flat interval/due field, or a legacy
|
||||
# schedule_type. A bare calendar-kind schedule_type (e.g. a client echoing
|
||||
# the derived "nth_weekday" the payload exposed) is NOT a flat rebuild signal
|
||||
# and must not collapse the calendar schedule to manual (the #58/#42 class).
|
||||
_flat_recurrence_edit = (
|
||||
any(k in msg for k in ("interval_days", "interval_unit", "interval_anchor", "due_date"))
|
||||
or msg.get("schedule_type") in FLAT_SCHEDULE_TYPES
|
||||
)
|
||||
if msg.get("schedule"):
|
||||
for key in FLAT_RECURRENCE_KEYS:
|
||||
task.pop(key, None)
|
||||
task["schedule"] = Schedule.from_dict(msg["schedule"]).to_dict()
|
||||
elif _flat_recurrence_edit:
|
||||
task.pop("schedule", None)
|
||||
elif "schedule_type" in msg:
|
||||
# Calendar-kind schedule_type echo with no schedule → keep the nested
|
||||
# schedule; drop the stray flat schedule_type so normalize stays clean.
|
||||
task.pop("schedule_type", None)
|
||||
|
||||
# Validate/cap newly-applied v1.3.0 fields. cap_task_fields runs the
|
||||
# full task sanitize (caches, lengths, action shape) so update-path
|
||||
# behaves identically to create-path.
|
||||
from ..helpers.sanitize import (
|
||||
cap_action_field,
|
||||
cap_quick_complete_defaults_field,
|
||||
sanitize_assignee_pool,
|
||||
sanitize_labels,
|
||||
)
|
||||
|
||||
cap_action_field(task)
|
||||
cap_quick_complete_defaults_field(task)
|
||||
if "labels" in task:
|
||||
task["labels"] = sanitize_labels(task["labels"])
|
||||
if "assignee_pool" in task:
|
||||
task["assignee_pool"] = sanitize_assignee_pool(task["assignee_pool"])
|
||||
|
||||
# Clear stale trigger runtime in Store only when trigger fundamentally changes
|
||||
if "trigger_config" in msg:
|
||||
old_tc = tasks_data.get(task_id, {}).get("trigger_config") or {}
|
||||
new_tc = msg["trigger_config"] or {}
|
||||
if (
|
||||
old_tc.get("type") != new_tc.get("type")
|
||||
or old_tc.get("entity_id") != new_tc.get("entity_id")
|
||||
or old_tc.get("entity_ids") != new_tc.get("entity_ids")
|
||||
):
|
||||
rd = _get_runtime_data(hass, msg["entry_id"])
|
||||
if rd and rd.store:
|
||||
rd.store.clear_trigger_runtime(task_id)
|
||||
rd.store.async_delay_save()
|
||||
|
||||
tasks_data[task_id] = normalize_task_storage(task)
|
||||
new_data = dict(entry.data)
|
||||
new_data[CONF_TASKS] = tasks_data
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
|
||||
# Reload entry to pick up changed task config (triggers, schedule, etc.)
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
result: dict[str, Any] = {"success": True}
|
||||
if tc_warnings:
|
||||
result["warnings"] = tc_warnings
|
||||
connection.send_result(msg["id"], result)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/task/delete",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_delete_task(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Delete a task from a maintenance object."""
|
||||
entry = _load_object_entry(hass, connection, msg)
|
||||
if entry is None:
|
||||
return
|
||||
|
||||
if not await async_delete_task(hass, entry, msg["task_id"]):
|
||||
connection.send_error(msg["id"], "not_found", "Task not found")
|
||||
return
|
||||
|
||||
# Reload to re-create remaining entities
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
|
||||
|
||||
async def async_delete_task(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
task_id: str,
|
||||
) -> bool:
|
||||
"""Remove a task and all its side-state from an object entry.
|
||||
|
||||
Shared by the ``task/delete`` WS command and the retention auto-delete sweep
|
||||
(helpers/retention). Does the ConfigEntry write plus the Store / notification
|
||||
/ entity-registry / group-ref / repair-issue cleanup, but NOT the entry
|
||||
reload and NOT any WS reply — the caller reloads (once, even for a batch) and
|
||||
replies. Returns False when ``task_id`` isn't in the entry.
|
||||
"""
|
||||
new_data = dict(entry.data)
|
||||
new_tasks = dict(new_data.get(CONF_TASKS, {}))
|
||||
if task_id not in new_tasks:
|
||||
return False
|
||||
|
||||
old_trigger_config = new_tasks[task_id].get("trigger_config")
|
||||
del new_tasks[task_id]
|
||||
new_data[CONF_TASKS] = new_tasks
|
||||
|
||||
# Remove from task_ids
|
||||
obj = dict(new_data.get(CONF_OBJECT, {}))
|
||||
obj["task_ids"] = [tid for tid in obj.get("task_ids", []) if tid != task_id]
|
||||
new_data[CONF_OBJECT] = obj
|
||||
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
|
||||
# Clean up Store
|
||||
rd = _get_runtime_data(hass, entry.entry_id)
|
||||
store = getattr(rd, "store", None) if rd else None
|
||||
if store is not None:
|
||||
store.remove_task(task_id)
|
||||
await store.async_save()
|
||||
|
||||
# Clean up notification state for deleted task
|
||||
nm = hass.data.get(DOMAIN, {}).get("_notification_manager")
|
||||
if nm is not None:
|
||||
nm.clear_task_state(entry.entry_id, task_id)
|
||||
|
||||
# Remove orphaned entity registry entries for the deleted task. Match any
|
||||
# per-task entity — sensor (`_{task_id}`), binary_sensor (`_{task_id}_overdue`),
|
||||
# action buttons (`_{task_id}_complete/skip/reset`) and any future platform.
|
||||
# task_id is a UUID, so the contained-segment check is unambiguous.
|
||||
ent_reg = er.async_get(hass)
|
||||
for ent_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id):
|
||||
if ent_entry.unique_id and f"_{task_id}" in ent_entry.unique_id:
|
||||
ent_reg.async_remove(ent_entry.entity_id)
|
||||
|
||||
# Clean up group references
|
||||
cleanup_group_refs(hass, task_id=task_id)
|
||||
|
||||
# Clean up the global vacation exempt list (journey L1): the list is
|
||||
# persistent and task-id keyed — without this, deleted ids accumulate
|
||||
# there forever and confuse the vacation preview UI.
|
||||
from ..const import CONF_VACATION_EXEMPT_TASK_IDS
|
||||
|
||||
for ge in hass.config_entries.async_entries(DOMAIN):
|
||||
if ge.unique_id != GLOBAL_UNIQUE_ID:
|
||||
continue
|
||||
exempt = ge.options.get(CONF_VACATION_EXEMPT_TASK_IDS) or []
|
||||
if isinstance(exempt, list) and task_id in exempt:
|
||||
hass.config_entries.async_update_entry(
|
||||
ge,
|
||||
options={
|
||||
**dict(ge.options),
|
||||
CONF_VACATION_EXEMPT_TASK_IDS: [t for t in exempt if t != task_id],
|
||||
},
|
||||
)
|
||||
break
|
||||
|
||||
# Clean up any repair issues referencing this task
|
||||
if old_trigger_config:
|
||||
from ..entity.triggers import normalize_entity_ids
|
||||
|
||||
for eid in normalize_entity_ids(old_trigger_config):
|
||||
ir.async_delete_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"missing_trigger_{entry.entry_id}_{task_id}_{eid}",
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/task/duplicate",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_duplicate_task(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Clone a task within the same object as a fresh, un-started copy.
|
||||
|
||||
Copies the source task's *configuration* (schedule, trigger, checklist,
|
||||
completion actions, …) — which lives in ConfigEntry.data — into a new task
|
||||
with a new id and a "… (copy)" name. Dynamic state (history, last_performed,
|
||||
trigger runtime) is not copied: the copy starts clean. Fields that must be
|
||||
unique per task (entity_slug, nfc_tag_id) are dropped so the copy gets its
|
||||
own auto-generated slug and no colliding tag.
|
||||
"""
|
||||
entry = _load_object_entry(hass, connection, msg)
|
||||
if entry is None:
|
||||
return
|
||||
|
||||
source = entry.data.get(CONF_TASKS, {}).get(msg["task_id"])
|
||||
if source is None:
|
||||
connection.send_error(msg["id"], "not_found", "Task not found")
|
||||
return
|
||||
|
||||
new_task = deepcopy(dict(source))
|
||||
new_task["id"] = uuid4().hex
|
||||
base_name = str(source.get("name", "")).strip() or "Task"
|
||||
new_task["name"] = f"{base_name} (copy)"[:MAX_NAME_LENGTH]
|
||||
new_task["created_at"] = dt_util.now().date().isoformat()
|
||||
# Never carry over per-task-unique keys or any stray dynamic state.
|
||||
for key in ("entity_slug", "nfc_tag_id", "history", "last_performed", "last_planned_due", "adaptive_config"):
|
||||
new_task.pop(key, None)
|
||||
if isinstance(new_task.get("trigger_config"), dict):
|
||||
new_task["trigger_config"].pop("_trigger_state", None)
|
||||
|
||||
await async_persist_task(hass, entry, new_task)
|
||||
|
||||
connection.send_result(msg["id"], {"task_id": new_task["id"]})
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Task history-entry edit WS handler."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from ..const import (
|
||||
MAX_ID_LENGTH,
|
||||
MAX_META_LENGTH,
|
||||
MAX_TEXT_LENGTH,
|
||||
HistoryEntryType,
|
||||
)
|
||||
from ..helpers.permissions import require_write
|
||||
from . import (
|
||||
_get_runtime_data,
|
||||
_load_object_entry,
|
||||
)
|
||||
|
||||
# v2.2.0 — edit existing history entries (Discussion #49 follow-up).
|
||||
#
|
||||
# Identifying the entry: by its CURRENT timestamp (the original_timestamp the
|
||||
# frontend last saw). Index would shift if the user completes a task in another
|
||||
# browser between read and write — timestamp is more stable. If multiple
|
||||
# entries share a timestamp (rare), the first match is patched.
|
||||
#
|
||||
# Patchable fields: timestamp, notes, cost, duration, completed_by. Anything
|
||||
# else (type, trigger_value, checklist_state, feedback) is intentionally
|
||||
# read-only — those carry semantic meaning that shouldn't be silently rewritten.
|
||||
#
|
||||
# After the patch we recompute last_performed if the edited entry is the
|
||||
# latest type=completed/reset/skipped entry — otherwise the next_due math
|
||||
# uses a stale anchor.
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/task/history/update",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
# ISO datetime string identifying the entry being edited.
|
||||
vol.Required("original_timestamp"): vol.All(str, vol.Length(max=64)),
|
||||
# Patch fields — all optional; absent fields stay unchanged.
|
||||
vol.Optional("timestamp"): vol.All(str, vol.Length(max=64)),
|
||||
vol.Optional("notes"): vol.Any(vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)), None),
|
||||
vol.Optional("cost"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0, max=1_000_000)), None),
|
||||
vol.Optional("duration"): vol.Any(vol.All(vol.Coerce(int), vol.Range(min=0, max=525_600)), None),
|
||||
vol.Optional("completed_by"): vol.Any(vol.All(str, vol.Length(max=MAX_META_LENGTH)), None),
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_update_history_entry(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Edit fields of an existing history entry."""
|
||||
entry = _load_object_entry(hass, connection, msg)
|
||||
if entry is None:
|
||||
return
|
||||
|
||||
rd = _get_runtime_data(hass, entry.entry_id)
|
||||
store = getattr(rd, "store", None) if rd else None
|
||||
if store is None:
|
||||
connection.send_error(msg["id"], "not_loaded", "Object not loaded")
|
||||
return
|
||||
|
||||
task_id = msg["task_id"]
|
||||
history = list(store.get_history(task_id))
|
||||
if not history:
|
||||
connection.send_error(msg["id"], "not_found", "Task or history not found")
|
||||
return
|
||||
|
||||
# Validate new timestamp format up front so we don't half-mutate
|
||||
if "timestamp" in msg:
|
||||
new_ts = msg["timestamp"]
|
||||
try:
|
||||
dt_util.parse_datetime(new_ts)
|
||||
if dt_util.parse_datetime(new_ts) is None:
|
||||
raise ValueError("not a datetime")
|
||||
except (ValueError, TypeError):
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
"invalid_date",
|
||||
"timestamp must be an ISO datetime string",
|
||||
)
|
||||
return
|
||||
|
||||
# Locate the entry by its original timestamp — first match wins.
|
||||
target_index: int | None = None
|
||||
for i, h in enumerate(history):
|
||||
if h.get("timestamp") == msg["original_timestamp"]:
|
||||
target_index = i
|
||||
break
|
||||
if target_index is None:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
"not_found",
|
||||
f"No history entry with timestamp {msg['original_timestamp']!r}",
|
||||
)
|
||||
return
|
||||
|
||||
patched = dict(history[target_index])
|
||||
|
||||
# Apply patch — explicit None means "clear field" (drop the key entirely
|
||||
# so the dict stays minimal); explicit value sets it.
|
||||
PATCHABLE = ("timestamp", "notes", "cost", "duration", "completed_by")
|
||||
for field in PATCHABLE:
|
||||
if field not in msg:
|
||||
continue
|
||||
value = msg[field]
|
||||
if value is None:
|
||||
patched.pop(field, None)
|
||||
else:
|
||||
patched[field] = value
|
||||
|
||||
history[target_index] = patched
|
||||
store.set_history(task_id, history)
|
||||
|
||||
# Recompute last_performed if the edited entry is the latest lifecycle
|
||||
# entry. Lifecycle = anything that resets the maintenance cycle:
|
||||
# COMPLETED, RESET, SKIPPED. Trigger / trigger_replaced entries don't
|
||||
# affect last_performed.
|
||||
LIFECYCLE_TYPES = {
|
||||
HistoryEntryType.COMPLETED,
|
||||
HistoryEntryType.RESET,
|
||||
HistoryEntryType.SKIPPED,
|
||||
}
|
||||
lifecycle_entries = [h for h in history if h.get("type") in LIFECYCLE_TYPES]
|
||||
if lifecycle_entries:
|
||||
# "Latest" by timestamp — sort defensively (entries are usually
|
||||
# already in append order, but a timestamp edit may have changed that).
|
||||
latest = max(
|
||||
lifecycle_entries,
|
||||
key=lambda h: h.get("timestamp", ""),
|
||||
)
|
||||
latest_ts = latest.get("timestamp")
|
||||
if latest_ts:
|
||||
new_lp = latest_ts[:10] # YYYY-MM-DD prefix
|
||||
store.set_last_performed(task_id, new_lp)
|
||||
|
||||
await store.async_save()
|
||||
|
||||
# Refresh coordinator + budget cache so the UI reflects the change
|
||||
if rd and rd.coordinator:
|
||||
rd.coordinator._recalculate_budget_cache()
|
||||
await rd.coordinator.async_request_refresh()
|
||||
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
"success": True,
|
||||
"patched_index": target_index,
|
||||
"new_timestamp": patched.get("timestamp"),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Task archive / unarchive / list WS handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from ..const import (
|
||||
ARCHIVE_REASON_MANUAL,
|
||||
CONF_OBJECT,
|
||||
CONF_OBJECT_NAME,
|
||||
CONF_TASKS,
|
||||
MAX_ID_LENGTH,
|
||||
)
|
||||
from ..helpers.permissions import require_write
|
||||
from . import (
|
||||
_build_task_summary,
|
||||
_get_merged_tasks,
|
||||
_get_object_entries,
|
||||
_get_runtime_data,
|
||||
_load_object_entry,
|
||||
)
|
||||
|
||||
|
||||
def _is_recurring_schedule(task: dict[str, Any]) -> bool:
|
||||
"""True iff the task has a cycling schedule (interval or a calendar kind).
|
||||
|
||||
One-off and manual tasks don't re-arm, so unarchiving them keeps their
|
||||
terminal state; a recurring task is given a fresh cycle instead (D2).
|
||||
Delegates to the shared predicate in helpers.schedule (also used by the
|
||||
seasonal-pause resume, N3).
|
||||
"""
|
||||
from ..helpers.schedule import is_recurring
|
||||
|
||||
return is_recurring(task)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/task/archive",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_archive_task(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Archive a single task (retire but retain).
|
||||
|
||||
Reason MANUAL → it is never auto-deleted and is unarchived individually
|
||||
(an object-cascade unarchive leaves it alone). Works for any task type.
|
||||
"""
|
||||
entry = _load_object_entry(hass, connection, msg)
|
||||
if entry is None:
|
||||
return
|
||||
|
||||
tasks_data = dict(entry.data.get(CONF_TASKS, {}))
|
||||
task_id = msg["task_id"]
|
||||
if task_id not in tasks_data:
|
||||
connection.send_error(msg["id"], "not_found", "Task not found")
|
||||
return
|
||||
|
||||
td = dict(tasks_data[task_id])
|
||||
if td.get("archived_at") is not None:
|
||||
connection.send_error(msg["id"], "already_archived", "Task already archived")
|
||||
return
|
||||
|
||||
td["archived_at"] = dt_util.now().isoformat()
|
||||
td["archived_reason"] = ARCHIVE_REASON_MANUAL
|
||||
tasks_data[task_id] = td
|
||||
new_data = dict(entry.data)
|
||||
new_data[CONF_TASKS] = tasks_data
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
|
||||
# Reload so a sensor task's triggers tear down (async_added_to_hass skips
|
||||
# trigger setup for archived tasks) and every per-task entity recomputes inert.
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
connection.send_result(msg["id"], {"success": True, "archived_at": td["archived_at"]})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/task/unarchive",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_unarchive_task(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Unarchive a single task.
|
||||
|
||||
Recurring tasks restart a fresh cycle (D2): ``last_performed`` is re-anchored
|
||||
to today so ``next_due = today + interval`` rather than resurfacing as
|
||||
retroactively overdue. One-off / manual tasks keep their terminal state.
|
||||
"""
|
||||
entry = _load_object_entry(hass, connection, msg)
|
||||
if entry is None:
|
||||
return
|
||||
|
||||
tasks_data = dict(entry.data.get(CONF_TASKS, {}))
|
||||
task_id = msg["task_id"]
|
||||
if task_id not in tasks_data:
|
||||
connection.send_error(msg["id"], "not_found", "Task not found")
|
||||
return
|
||||
|
||||
td = dict(tasks_data[task_id])
|
||||
if td.get("archived_at") is None:
|
||||
connection.send_error(msg["id"], "not_archived", "Task is not archived")
|
||||
return
|
||||
|
||||
td.pop("archived_at", None)
|
||||
td.pop("archived_reason", None)
|
||||
|
||||
# Fresh cycle for recurring tasks. last_performed is dynamic state → Store
|
||||
# when present, else the static dict (legacy). One-off/manual: no re-anchor.
|
||||
rd = _get_runtime_data(hass, entry.entry_id)
|
||||
store = getattr(rd, "store", None) if rd else None
|
||||
if _is_recurring_schedule(td):
|
||||
today_iso = dt_util.now().date().isoformat()
|
||||
if store is not None:
|
||||
store.set_last_performed(task_id, today_iso)
|
||||
state = store._ensure_task(task_id)
|
||||
state.pop("last_planned_due", None)
|
||||
await store.async_save()
|
||||
else:
|
||||
td["last_performed"] = today_iso
|
||||
td.pop("last_planned_due", None)
|
||||
|
||||
tasks_data[task_id] = td
|
||||
new_data = dict(entry.data)
|
||||
new_data[CONF_TASKS] = tasks_data
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task List
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/task/list",
|
||||
vol.Optional("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
}
|
||||
)
|
||||
@callback
|
||||
def ws_list_tasks(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""List tasks, optionally filtered by entry_id (object)."""
|
||||
entries = _get_object_entries(hass)
|
||||
filter_entry_id = msg.get("entry_id")
|
||||
|
||||
tasks: list[dict[str, Any]] = []
|
||||
for entry in entries:
|
||||
if filter_entry_id and entry.entry_id != filter_entry_id:
|
||||
continue
|
||||
entry_tasks = _get_merged_tasks(entry)
|
||||
obj_data = entry.data.get(CONF_OBJECT, {})
|
||||
rd = _get_runtime_data(hass, entry.entry_id)
|
||||
coordinator_data = rd.coordinator.data if rd and rd.coordinator else None
|
||||
ct_tasks = (coordinator_data or {}).get(CONF_TASKS, {})
|
||||
for task_id, task_data in entry_tasks.items():
|
||||
summary = _build_task_summary(hass, task_id, task_data, ct_tasks.get(task_id))
|
||||
summary["task_id"] = task_id
|
||||
summary["entry_id"] = entry.entry_id
|
||||
summary["object_name"] = obj_data.get(CONF_OBJECT_NAME, "")
|
||||
tasks.append(summary)
|
||||
|
||||
connection.send_result(msg["id"], {"tasks": tasks})
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Task persistence primitives shared by CRUD + the add_task service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from ..const import (
|
||||
CONF_OBJECT,
|
||||
CONF_TASKS,
|
||||
DEFAULT_WARNING_DAYS,
|
||||
DOMAIN,
|
||||
GLOBAL_UNIQUE_ID,
|
||||
)
|
||||
from ..helpers.schedule import (
|
||||
normalize_task_storage,
|
||||
)
|
||||
from . import (
|
||||
_get_runtime_data,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def async_persist_task(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
task_data: dict[str, Any],
|
||||
*,
|
||||
last_performed: str | None = None,
|
||||
history: list[dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
"""Persist a freshly-built task into an object entry and reload it.
|
||||
|
||||
Shared by the ``task/create`` WS command and the ``add_task`` service
|
||||
(DRY): updates ConfigEntry.data + the object's task_ids, initializes the
|
||||
Store dynamic state, and reloads the entry so the task's entities
|
||||
(sensor / binary_sensor / buttons) are created.
|
||||
"""
|
||||
# Store recurrence in the canonical nested `schedule` shape (schedule-model v2).
|
||||
task_data = normalize_task_storage(task_data)
|
||||
task_id = task_data["id"]
|
||||
new_data = dict(entry.data)
|
||||
new_tasks = dict(new_data.get(CONF_TASKS, {}))
|
||||
new_tasks[task_id] = task_data
|
||||
new_data[CONF_TASKS] = new_tasks
|
||||
|
||||
obj = dict(new_data.get(CONF_OBJECT, {}))
|
||||
task_ids = list(obj.get("task_ids", []))
|
||||
task_ids.append(task_id)
|
||||
obj["task_ids"] = task_ids
|
||||
new_data[CONF_OBJECT] = obj
|
||||
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
|
||||
rd = _get_runtime_data(hass, entry.entry_id)
|
||||
store = getattr(rd, "store", None) if rd else None
|
||||
if store is not None:
|
||||
store.init_task(task_id, last_performed=last_performed)
|
||||
if history:
|
||||
store.set_history(task_id, history)
|
||||
await store.async_save()
|
||||
else:
|
||||
# Legacy: dynamic fields live in ConfigEntry.data
|
||||
task_data["last_performed"] = last_performed
|
||||
task_data["history"] = history or []
|
||||
new_tasks[task_id] = task_data
|
||||
new_data[CONF_TASKS] = new_tasks
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_create_task_simple(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
entry_id: str,
|
||||
name: str,
|
||||
task_type: str = "custom",
|
||||
schedule_type: str = "time_based",
|
||||
interval_days: int | None = None,
|
||||
interval_unit: str = "days",
|
||||
due_date: str | None = None,
|
||||
warning_days: int = DEFAULT_WARNING_DAYS,
|
||||
enabled: bool = True,
|
||||
notes: str | None = None,
|
||||
schedule: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""Create a task with the common fields and persist it; return task_id.
|
||||
|
||||
The service-facing creation path — a focused subset of ws_create_task's
|
||||
field set — sharing :func:`async_persist_task` with the WS handler (DRY).
|
||||
For the full field set (triggers, checklists, completion actions, …) use
|
||||
the panel / card dialogs or the ``task/create`` WS command.
|
||||
|
||||
Raises ValueError if the entry_id is not a maintenance object or the name
|
||||
is empty.
|
||||
"""
|
||||
entry = hass.config_entries.async_get_entry(entry_id)
|
||||
if entry is None or entry.domain != DOMAIN or entry.unique_id == GLOBAL_UNIQUE_ID:
|
||||
raise ValueError(f"No maintenance object found for entry_id {entry_id!r}")
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("Name must not be empty")
|
||||
task_data: dict[str, Any] = {
|
||||
"id": uuid4().hex,
|
||||
"object_id": entry.data.get(CONF_OBJECT, {}).get("id", ""),
|
||||
"name": name,
|
||||
"type": task_type,
|
||||
"enabled": enabled,
|
||||
"schedule_type": schedule_type,
|
||||
"warning_days": warning_days,
|
||||
"created_at": dt_util.now().date().isoformat(),
|
||||
}
|
||||
if schedule:
|
||||
# Calendar kinds: persist the nested schedule (normalize treats it as
|
||||
# authoritative over the flat fields).
|
||||
task_data["schedule"] = schedule
|
||||
if interval_days is not None:
|
||||
task_data["interval_days"] = interval_days
|
||||
if interval_unit and interval_unit != "days":
|
||||
task_data["interval_unit"] = interval_unit
|
||||
if due_date:
|
||||
task_data["due_date"] = due_date
|
||||
if notes:
|
||||
task_data["notes"] = notes
|
||||
await async_persist_task(hass, entry, task_data)
|
||||
return task_data["id"]
|
||||
|
||||
|
||||
_UPDATABLE_FLAT_FIELDS = (
|
||||
"name",
|
||||
"type",
|
||||
"interval_days",
|
||||
"interval_unit",
|
||||
"due_date",
|
||||
"warning_days",
|
||||
"enabled",
|
||||
"notes",
|
||||
"priority",
|
||||
"labels",
|
||||
)
|
||||
|
||||
|
||||
async def async_update_task_simple(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
entry_id: str,
|
||||
task_id: str,
|
||||
updates: dict[str, Any],
|
||||
) -> None:
|
||||
"""Patch the common task fields and persist; the service-facing edit path.
|
||||
|
||||
Mirror of :func:`async_create_task_simple` for edits — a focused subset of
|
||||
the ``task/update`` WS field set for automations/scripts/voice. Present
|
||||
keys in *updates* overwrite; absent keys are untouched. Recurrence changes
|
||||
(flat fields or a nested ``schedule``) go through
|
||||
:func:`normalize_task_storage`, so partial edits keep the unit/anchor
|
||||
semantics of the storage model (issue #58 class).
|
||||
|
||||
Raises ValueError for an unknown entry/task or an empty name.
|
||||
"""
|
||||
entry = hass.config_entries.async_get_entry(entry_id)
|
||||
if entry is None or entry.domain != DOMAIN or entry.unique_id == GLOBAL_UNIQUE_ID:
|
||||
raise ValueError(f"No maintenance object found for entry_id {entry_id!r}")
|
||||
|
||||
new_data = dict(entry.data)
|
||||
new_tasks = dict(new_data.get(CONF_TASKS, {}))
|
||||
if task_id not in new_tasks:
|
||||
raise ValueError(f"No task {task_id!r} in {entry.title!r}")
|
||||
|
||||
task = dict(new_tasks[task_id])
|
||||
for key in _UPDATABLE_FLAT_FIELDS:
|
||||
if key in updates and updates[key] is not None:
|
||||
task[key] = updates[key]
|
||||
if isinstance(task.get("name"), str):
|
||||
task["name"] = task["name"].strip()
|
||||
if not task["name"]:
|
||||
raise ValueError("Name must not be empty")
|
||||
if updates.get("schedule_type") is not None:
|
||||
task["schedule_type"] = updates["schedule_type"]
|
||||
if updates.get("schedule"):
|
||||
task["schedule"] = updates["schedule"]
|
||||
|
||||
new_tasks[task_id] = normalize_task_storage(task)
|
||||
new_data[CONF_TASKS] = new_tasks
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
await hass.config_entries.async_reload(entry_id)
|
||||
@@ -0,0 +1,249 @@
|
||||
"""URL-safety, NFC-duplicate, and trigger-config validation helpers.
|
||||
|
||||
Shared base layer for the task WS handlers (imported by tasks_crud) and by
|
||||
several sibling modules (objects, documents, io) + tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..const import (
|
||||
CONF_OBJECT,
|
||||
CONF_OBJECT_NAME,
|
||||
CONF_TASKS,
|
||||
TriggerType,
|
||||
)
|
||||
from . import (
|
||||
_get_object_entries,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SAFE_URL_SCHEMES = {"http", "https"}
|
||||
|
||||
|
||||
def _is_safe_url(url: str | None) -> bool:
|
||||
"""Reject javascript:, data:, protocol-relative and other dangerous URLs.
|
||||
|
||||
Only http/https and genuine path-relative URLs (no host) pass. ASCII control
|
||||
characters and surrounding whitespace are stripped first, since urlparse and
|
||||
browsers ignore them and they can otherwise mask a "//host" or scheme-less
|
||||
host (e.g. ``" //evil.com"`` or ``"\t//evil.com"``).
|
||||
"""
|
||||
if not url:
|
||||
return True
|
||||
from urllib.parse import urlparse
|
||||
|
||||
cleaned = "".join(ch for ch in url if ch.isprintable()).strip()
|
||||
if not cleaned:
|
||||
return True
|
||||
# Block protocol-relative URLs like //evil.com
|
||||
if cleaned.startswith("//"):
|
||||
return False
|
||||
try:
|
||||
parsed = urlparse(cleaned)
|
||||
except Exception: # noqa: BLE001 - any malformed URL is rejected as unsafe
|
||||
return False
|
||||
scheme = parsed.scheme.lower()
|
||||
if scheme in _SAFE_URL_SCHEMES:
|
||||
return True
|
||||
# An empty scheme is only safe for a true path-relative URL with no host.
|
||||
return scheme == "" and not parsed.netloc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NFC tag uniqueness check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _check_nfc_tag_duplicate(hass: HomeAssistant, nfc_tag_id: str, exclude_task_id: str | None = None) -> str | None:
|
||||
"""Check if an NFC tag ID is already in use by another task.
|
||||
|
||||
Returns a warning message if duplicate, or None.
|
||||
"""
|
||||
for entry in _get_object_entries(hass):
|
||||
tasks = entry.data.get(CONF_TASKS, {})
|
||||
obj_name = entry.data.get(CONF_OBJECT, {}).get(CONF_OBJECT_NAME, "")
|
||||
for tid, tdata in tasks.items():
|
||||
if tid == exclude_task_id:
|
||||
continue
|
||||
if tdata.get("nfc_tag_id") == nfc_tag_id:
|
||||
return f"NFC tag '{nfc_tag_id}' is already linked to task '{tdata.get('name', tid)}' on object '{obj_name}'"
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trigger config validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Derived from the enum so the server validator can't drift from the type set.
|
||||
_VALID_TRIGGER_TYPES = frozenset(t.value for t in TriggerType)
|
||||
|
||||
_TRIGGER_REQUIRED_FIELDS: dict[str, list[str]] = {
|
||||
"threshold": [], # at least one of trigger_above/trigger_below checked below
|
||||
"counter": ["trigger_target_value"],
|
||||
"state_change": [],
|
||||
"runtime": ["trigger_runtime_hours"],
|
||||
"compound": [], # conditions validated separately
|
||||
}
|
||||
|
||||
_TRIGGER_ALLOWED_KEYS: set[str] = {
|
||||
"type",
|
||||
"entity_id",
|
||||
"entity_ids",
|
||||
"entity_logic",
|
||||
"attribute",
|
||||
# threshold
|
||||
"trigger_above",
|
||||
"trigger_below",
|
||||
"trigger_for_minutes",
|
||||
# counter
|
||||
"trigger_target_value",
|
||||
"trigger_delta_mode",
|
||||
# runtime
|
||||
"trigger_runtime_hours",
|
||||
"trigger_on_states",
|
||||
# state_change
|
||||
"trigger_from_state",
|
||||
"trigger_to_state",
|
||||
"trigger_target_changes",
|
||||
# compound
|
||||
"compound_logic",
|
||||
"conditions",
|
||||
# record a completion when the trigger clears itself (#53)
|
||||
"auto_complete_on_recovery",
|
||||
}
|
||||
|
||||
|
||||
def _validate_trigger_config(
|
||||
hass: HomeAssistant,
|
||||
trigger_config: dict[str, Any],
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Validate trigger_config structure.
|
||||
|
||||
Returns (errors, warnings).
|
||||
Accepts both ``entity_id`` (str) and ``entity_ids`` (list[str]).
|
||||
"""
|
||||
from ..entity.triggers import normalize_entity_ids
|
||||
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
# Trigger type
|
||||
trigger_type = trigger_config.get("type", "threshold")
|
||||
if trigger_type not in _VALID_TRIGGER_TYPES:
|
||||
errors.append(f"Invalid trigger type '{trigger_type}'. Must be one of: {', '.join(sorted(_VALID_TRIGGER_TYPES))}")
|
||||
return errors, warnings
|
||||
|
||||
# --- Compound triggers ---
|
||||
if trigger_type == "compound":
|
||||
return _validate_compound_trigger(hass, trigger_config)
|
||||
|
||||
# --- Non-compound: entity validation ---
|
||||
entity_ids = normalize_entity_ids(trigger_config)
|
||||
if not entity_ids:
|
||||
errors.append("trigger_config requires entity_id or entity_ids")
|
||||
else:
|
||||
for eid in entity_ids:
|
||||
state = hass.states.get(eid)
|
||||
if state is None:
|
||||
warnings.append(f"Entity {eid} does not exist (yet)")
|
||||
elif state.state in ("unavailable", "unknown"):
|
||||
warnings.append(f"Entity {eid} is currently '{state.state}'")
|
||||
# Ensure entity_id is set for backwards compat
|
||||
if not trigger_config.get("entity_id"):
|
||||
trigger_config["entity_id"] = entity_ids[0]
|
||||
# Always store entity_ids list
|
||||
trigger_config["entity_ids"] = entity_ids
|
||||
|
||||
# Validate entity_logic
|
||||
entity_logic = trigger_config.get("entity_logic")
|
||||
if entity_logic is not None and entity_logic not in ("any", "all"):
|
||||
errors.append(f"trigger_config.entity_logic must be 'any' or 'all', got '{entity_logic}'")
|
||||
|
||||
# Required fields per type
|
||||
for field in _TRIGGER_REQUIRED_FIELDS[trigger_type]:
|
||||
if trigger_config.get(field) is None:
|
||||
errors.append(f"trigger_config.{field} is required for type '{trigger_type}'")
|
||||
|
||||
# Threshold: at least one of trigger_above or trigger_below
|
||||
if trigger_type == "threshold":
|
||||
if trigger_config.get("trigger_above") is None and trigger_config.get("trigger_below") is None:
|
||||
errors.append("trigger_config requires at least one of 'trigger_above' or 'trigger_below' for type 'threshold'")
|
||||
|
||||
# Runtime: validate trigger_on_states if provided
|
||||
if trigger_type == "runtime":
|
||||
on_states = trigger_config.get("trigger_on_states")
|
||||
if on_states is not None:
|
||||
if not isinstance(on_states, list) or not all(isinstance(s, str) and s.strip() for s in on_states):
|
||||
errors.append("trigger_config.trigger_on_states must be a list of non-empty strings")
|
||||
elif len(on_states) == 0:
|
||||
errors.append("trigger_config.trigger_on_states must not be empty when provided")
|
||||
|
||||
# Strip unknown keys to prevent data pollution
|
||||
unknown = set(trigger_config) - _TRIGGER_ALLOWED_KEYS
|
||||
for key in unknown:
|
||||
del trigger_config[key]
|
||||
|
||||
# Coerce the recovery flag to a real bool (drop it when falsy so stored
|
||||
# configs stay minimal — absence means off).
|
||||
if "auto_complete_on_recovery" in trigger_config:
|
||||
if trigger_config["auto_complete_on_recovery"]:
|
||||
trigger_config["auto_complete_on_recovery"] = True
|
||||
else:
|
||||
del trigger_config["auto_complete_on_recovery"]
|
||||
|
||||
# Normalise state_change from/to: HA's state machine stores values lowercase
|
||||
# ("on"/"off"/"home"/...). Users typing "ON"/"OFF" expect a match — same
|
||||
# forgiving treatment as the runtime trigger, which lowercases trigger_on_states.
|
||||
if trigger_type == "state_change":
|
||||
for key in ("trigger_from_state", "trigger_to_state"):
|
||||
val = trigger_config.get(key)
|
||||
if isinstance(val, str):
|
||||
stripped = val.strip().lower()
|
||||
if stripped:
|
||||
trigger_config[key] = stripped
|
||||
else:
|
||||
trigger_config.pop(key, None)
|
||||
|
||||
return errors, warnings
|
||||
|
||||
|
||||
def _validate_compound_trigger(
|
||||
hass: HomeAssistant,
|
||||
trigger_config: dict[str, Any],
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Validate a compound trigger config."""
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
compound_logic = trigger_config.get("compound_logic", "AND").upper()
|
||||
if compound_logic not in ("AND", "OR"):
|
||||
errors.append(f"compound_logic must be 'AND' or 'OR', got '{compound_logic}'")
|
||||
|
||||
conditions = trigger_config.get("conditions")
|
||||
if not isinstance(conditions, list) or len(conditions) < 2:
|
||||
errors.append("Compound trigger requires 'conditions' list with at least 2 entries")
|
||||
return errors, warnings
|
||||
|
||||
for idx, condition in enumerate(conditions):
|
||||
if not isinstance(condition, dict):
|
||||
errors.append(f"Condition {idx} must be a dict")
|
||||
continue
|
||||
cond_type = condition.get("type", "threshold")
|
||||
if cond_type == "compound":
|
||||
errors.append(f"Condition {idx}: nested compound triggers are not allowed")
|
||||
continue
|
||||
# Validate each condition as a regular trigger
|
||||
cond_errors, cond_warnings = _validate_trigger_config(hass, condition)
|
||||
for err in cond_errors:
|
||||
errors.append(f"Condition {idx}: {err}")
|
||||
for warn in cond_warnings:
|
||||
warnings.append(f"Condition {idx}: {warn}")
|
||||
|
||||
return errors, warnings
|
||||
@@ -0,0 +1,152 @@
|
||||
"""WebSocket handlers for user management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..const import (
|
||||
CONF_OBJECT,
|
||||
CONF_TASKS,
|
||||
DOMAIN,
|
||||
MAX_ID_LENGTH,
|
||||
MAX_META_LENGTH,
|
||||
)
|
||||
from ..helpers.permissions import require_write, user_may_write
|
||||
from . import (
|
||||
_build_task_summary,
|
||||
_get_merged_tasks,
|
||||
_get_object_entries,
|
||||
_get_runtime_data,
|
||||
_load_object_entry,
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/users/list"})
|
||||
@websocket_api.async_response
|
||||
async def ws_list_users(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return list of HA users for task assignment.
|
||||
|
||||
Returns users with their basic info for frontend selector.
|
||||
Filters out system users (is_active=False, system_generated=True).
|
||||
"""
|
||||
users_data = []
|
||||
# Only admins receive the is_admin / is_owner flags. A non-admin caller gets
|
||||
# just id + name (needed for the assignee selector and name display) so they
|
||||
# cannot enumerate who is an admin/owner via this command.
|
||||
caller_is_admin = connection.user is not None and connection.user.is_admin
|
||||
|
||||
for user in await hass.auth.async_get_users():
|
||||
# Filter out system users and inactive users
|
||||
if not user.is_active or user.system_generated:
|
||||
continue
|
||||
|
||||
entry: dict[str, Any] = {"id": user.id, "name": user.name}
|
||||
if caller_is_admin:
|
||||
entry["is_admin"] = user.is_admin
|
||||
entry["is_owner"] = user.is_owner
|
||||
users_data.append(entry)
|
||||
|
||||
connection.send_result(msg["id"], {"users": users_data})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): f"{DOMAIN}/task/assign_user",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Optional("user_id"): vol.Any(vol.All(str, vol.Length(max=MAX_META_LENGTH)), None), # None = unassign
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_assign_user(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Assign or unassign a user to a task."""
|
||||
entry = _load_object_entry(hass, connection, msg)
|
||||
if entry is None:
|
||||
return
|
||||
|
||||
tasks_data = dict(entry.data.get(CONF_TASKS, {}))
|
||||
task_id = msg["task_id"]
|
||||
if task_id not in tasks_data:
|
||||
connection.send_error(msg["id"], "not_found", "Task not found")
|
||||
return
|
||||
|
||||
user_id = msg.get("user_id")
|
||||
|
||||
# Validate user exists if provided
|
||||
if user_id:
|
||||
user = await hass.auth.async_get_user(user_id)
|
||||
if user is None:
|
||||
connection.send_error(msg["id"], "invalid_user", "User not found")
|
||||
return
|
||||
|
||||
task = dict(tasks_data[task_id])
|
||||
if user_id is None:
|
||||
# Unassign user - remove field if it exists
|
||||
task.pop("responsible_user_id", None)
|
||||
else:
|
||||
task["responsible_user_id"] = user_id
|
||||
tasks_data[task_id] = task
|
||||
|
||||
new_data = dict(entry.data)
|
||||
new_data[CONF_TASKS] = tasks_data
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
|
||||
# Refresh coordinator
|
||||
rd = _get_runtime_data(hass, entry.entry_id)
|
||||
if rd and rd.coordinator:
|
||||
await rd.coordinator.async_request_refresh()
|
||||
|
||||
connection.send_result(msg["id"], {"success": True, "user_id": user_id})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): f"{DOMAIN}/tasks/by_user",
|
||||
vol.Required("user_id"): vol.All(str, vol.Length(max=MAX_META_LENGTH)),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_tasks_by_user(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return all tasks assigned to a specific user across all objects."""
|
||||
user_id = msg["user_id"]
|
||||
# Authorization: a user may query their OWN assignments; querying another
|
||||
# user's requires write access (admin or operator) — prevents a plain user
|
||||
# from enumerating another user's task assignments.
|
||||
if connection.user is None or (user_id != connection.user.id and not user_may_write(hass, connection)):
|
||||
connection.send_error(msg["id"], "unauthorized", "Not authorized to view another user's tasks")
|
||||
return
|
||||
entries = _get_object_entries(hass)
|
||||
result = []
|
||||
|
||||
for entry in entries:
|
||||
rd = _get_runtime_data(hass, entry.entry_id)
|
||||
coord_data = rd.coordinator.data if rd and rd.coordinator else None
|
||||
ct_tasks = (coord_data or {}).get(CONF_TASKS, {})
|
||||
tasks_data = _get_merged_tasks(entry)
|
||||
obj_data = entry.data.get(CONF_OBJECT, {})
|
||||
|
||||
for tid, tdata in tasks_data.items():
|
||||
if tdata.get("responsible_user_id") == user_id:
|
||||
task_summary = _build_task_summary(hass, tid, tdata, ct_tasks.get(tid))
|
||||
task_summary["object_name"] = obj_data.get("name", "")
|
||||
task_summary["entry_id"] = entry.entry_id
|
||||
result.append(task_summary)
|
||||
|
||||
connection.send_result(msg["id"], {"tasks": result})
|
||||
@@ -0,0 +1,234 @@
|
||||
"""WebSocket endpoints for vacation mode (v1.2.0)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from ..const import (
|
||||
CONF_OBJECT,
|
||||
CONF_TASKS,
|
||||
CONF_VACATION_BUFFER_DAYS,
|
||||
CONF_VACATION_ENABLED,
|
||||
CONF_VACATION_END,
|
||||
CONF_VACATION_EXEMPT_TASK_IDS,
|
||||
CONF_VACATION_START,
|
||||
DEFAULT_WARNING_DAYS,
|
||||
DOMAIN,
|
||||
MAX_ID_LENGTH,
|
||||
)
|
||||
from ..helpers.schedule import read_legacy_fields
|
||||
from ..helpers.vacation import compute_preview, get_vacation_state
|
||||
from . import _get_global_entry, _get_object_entries
|
||||
|
||||
|
||||
def _state_payload(hass: HomeAssistant) -> dict[str, Any]:
|
||||
"""Serialise the current VacationState for the wire."""
|
||||
return get_vacation_state(hass).as_wire_dict()
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/vacation/state"})
|
||||
@websocket_api.async_response
|
||||
async def ws_vacation_state(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return the current vacation configuration + active flag."""
|
||||
connection.send_result(msg["id"], _state_payload(hass))
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): f"{DOMAIN}/vacation/update",
|
||||
vol.Optional("enabled"): bool,
|
||||
vol.Optional("start"): vol.Any(vol.All(str, vol.Length(max=10)), None),
|
||||
vol.Optional("end"): vol.Any(vol.All(str, vol.Length(max=10)), None),
|
||||
vol.Optional("buffer_days"): vol.All(int, vol.Range(min=0, max=14)),
|
||||
vol.Optional("exempt_task_ids"): vol.All(
|
||||
[vol.All(str, vol.Length(max=MAX_ID_LENGTH))],
|
||||
vol.Length(max=2000),
|
||||
),
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def ws_vacation_update(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Patch vacation config on the global entry. Partial updates allowed."""
|
||||
global_entry = _get_global_entry(hass)
|
||||
if global_entry is None:
|
||||
connection.send_error(msg["id"], "not_found", "Global entry not found")
|
||||
return
|
||||
|
||||
options = dict(global_entry.options or global_entry.data)
|
||||
|
||||
if "enabled" in msg:
|
||||
options[CONF_VACATION_ENABLED] = bool(msg["enabled"])
|
||||
|
||||
if "start" in msg:
|
||||
if msg["start"] is None:
|
||||
options[CONF_VACATION_START] = None
|
||||
else:
|
||||
try:
|
||||
date.fromisoformat(msg["start"])
|
||||
except (TypeError, ValueError):
|
||||
connection.send_error(msg["id"], "invalid_date", "start must be YYYY-MM-DD")
|
||||
return
|
||||
options[CONF_VACATION_START] = msg["start"]
|
||||
|
||||
if "end" in msg:
|
||||
if msg["end"] is None:
|
||||
options[CONF_VACATION_END] = None
|
||||
else:
|
||||
try:
|
||||
date.fromisoformat(msg["end"])
|
||||
except (TypeError, ValueError):
|
||||
connection.send_error(msg["id"], "invalid_date", "end must be YYYY-MM-DD")
|
||||
return
|
||||
options[CONF_VACATION_END] = msg["end"]
|
||||
|
||||
# End-vs-start sanity (only when both are present after the patch).
|
||||
sd = options.get(CONF_VACATION_START)
|
||||
ed = options.get(CONF_VACATION_END)
|
||||
if sd and ed:
|
||||
try:
|
||||
if date.fromisoformat(ed) < date.fromisoformat(sd):
|
||||
connection.send_error(msg["id"], "invalid_range", "end must be on or after start")
|
||||
return
|
||||
except (TypeError, ValueError):
|
||||
pass # Already rejected above
|
||||
|
||||
if "buffer_days" in msg:
|
||||
options[CONF_VACATION_BUFFER_DAYS] = int(msg["buffer_days"])
|
||||
|
||||
if "exempt_task_ids" in msg:
|
||||
# Sanitise: strip + dedupe + cap.
|
||||
seen: set[str] = set()
|
||||
cleaned: list[str] = []
|
||||
for raw in msg["exempt_task_ids"]:
|
||||
if not isinstance(raw, str):
|
||||
continue
|
||||
v = raw.strip()
|
||||
if not v or len(v) > MAX_ID_LENGTH or v in seen:
|
||||
continue
|
||||
seen.add(v)
|
||||
cleaned.append(v)
|
||||
if len(cleaned) >= 2000:
|
||||
break
|
||||
options[CONF_VACATION_EXEMPT_TASK_IDS] = cleaned
|
||||
|
||||
hass.config_entries.async_update_entry(global_entry, options=options)
|
||||
connection.send_result(msg["id"], _state_payload(hass))
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/vacation/preview"})
|
||||
@websocket_api.async_response
|
||||
async def ws_vacation_preview(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return the projected impact of the currently-configured vacation.
|
||||
|
||||
Even works when the toggle is off — useful for the "Preview impact"
|
||||
button before the user enables it. A vacation without start/end returns
|
||||
an empty list.
|
||||
"""
|
||||
state = get_vacation_state(hass)
|
||||
|
||||
# If the user is previewing without enabling, still compute against the
|
||||
# currently-stored dates. Caller is responsible for passing dates via
|
||||
# /update first if they want a live preview during date entry.
|
||||
if state.start is None or state.end is None:
|
||||
connection.send_result(msg["id"], {"rows": [], "window_end": None})
|
||||
return
|
||||
|
||||
# Build the flat task list expected by compute_preview.
|
||||
tasks: list[dict[str, Any]] = []
|
||||
for entry in _get_object_entries(hass):
|
||||
obj_data = entry.data.get(CONF_OBJECT, {})
|
||||
obj_name = obj_data.get("name", "")
|
||||
tasks_data = entry.data.get(CONF_TASKS, {})
|
||||
|
||||
# Merge dynamic store fields (last_performed, etc.) when available.
|
||||
rd = getattr(entry, "runtime_data", None)
|
||||
store = getattr(rd, "store", None) if rd else None
|
||||
merged: dict[str, dict[str, Any]] = store.merge_all_tasks(tasks_data) if store is not None else dict(tasks_data)
|
||||
|
||||
for task_id, task_data in merged.items():
|
||||
sched = read_legacy_fields(task_data)
|
||||
tasks.append(
|
||||
{
|
||||
"task_id": task_id,
|
||||
"entry_id": entry.entry_id,
|
||||
"object_name": obj_name,
|
||||
"task_name": task_data.get("name", ""),
|
||||
"schedule_type": sched["schedule_type"],
|
||||
"interval_days": sched["interval_days"],
|
||||
"interval_unit": sched["interval_unit"],
|
||||
# Nested schedule so the preview can project calendar kinds.
|
||||
"schedule": task_data.get("schedule"),
|
||||
"warning_days": task_data.get("warning_days", DEFAULT_WARNING_DAYS),
|
||||
"last_performed": task_data.get("last_performed"),
|
||||
"created_at": task_data.get("created_at"),
|
||||
"enabled": task_data.get("enabled", True),
|
||||
}
|
||||
)
|
||||
|
||||
rows = compute_preview(state, tasks)
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
"rows": rows,
|
||||
"window_end": state.window_end.isoformat() if state.window_end else None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/vacation/end_now"})
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def ws_vacation_end_now(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Disable vacation mode immediately, preserve the date config for reuse."""
|
||||
global_entry = _get_global_entry(hass)
|
||||
if global_entry is None:
|
||||
connection.send_error(msg["id"], "not_found", "Global entry not found")
|
||||
return
|
||||
|
||||
options = dict(global_entry.options or global_entry.data)
|
||||
options[CONF_VACATION_ENABLED] = False
|
||||
# Optionally clamp end-date to today so the historical record reflects when
|
||||
# the user actually returned. Use HA's configured timezone — the user's
|
||||
# "today" should match what their dashboard shows, not the server's UTC.
|
||||
today = dt_util.now().date()
|
||||
sd = options.get(CONF_VACATION_START)
|
||||
if sd:
|
||||
try:
|
||||
if date.fromisoformat(sd) <= today:
|
||||
options[CONF_VACATION_END] = today.isoformat()
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
hass.config_entries.async_update_entry(global_entry, options=options)
|
||||
connection.send_result(msg["id"], _state_payload(hass))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ws_vacation_end_now",
|
||||
"ws_vacation_preview",
|
||||
"ws_vacation_state",
|
||||
"ws_vacation_update",
|
||||
]
|
||||
Reference in New Issue
Block a user