New apps Added

This commit is contained in:
2026-07-08 10:43:39 -04:00
parent 3b1f4bbd75
commit fefc2c8b5c
1114 changed files with 406637 additions and 154 deletions
@@ -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)
@@ -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 (0023 : 0059). 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",
]