200 files

This commit is contained in:
Home Assistant Version Control
2026-09-11 12:11:18 +00:00
parent ff4156dc89
commit 8769f3b12b
201 changed files with 5190 additions and 24141 deletions
@@ -160,6 +160,8 @@ def _build_task_summary(
"require_tag_scan": bool(task_data.get("require_tag_scan", False)),
# #150: skip lock — False disables skipping on every surface.
"allow_skip": task_data.get("allow_skip") is not False,
# #173: False = this task sends no reminders.
"notify_enabled": task_data.get("notify_enabled") is not False,
# v2.20 (#83): unit for `reading`-type tasks; values live in history.
"reading_unit": task_data.get("reading_unit"),
# #161 phase 2: reading slots [{id, name, unit}] ([] = single value).
@@ -651,6 +653,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
ws_delete_task,
ws_duplicate_task,
ws_list_tasks,
ws_move_task,
ws_postpone_task,
ws_quick_complete_task,
ws_reset_task,
@@ -688,6 +691,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
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_move_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)
@@ -796,6 +796,7 @@ async def ws_import_json(
"nfc_tag_id",
"require_tag_scan",
"allow_skip",
"notify_enabled",
"responsible_user_id",
"entity_slug",
"trigger_config",
@@ -22,6 +22,7 @@ from .tasks_crud import (
ws_create_task,
ws_delete_task,
ws_duplicate_task,
ws_move_task,
ws_update_task,
)
from .tasks_history import ws_update_history_entry
@@ -67,6 +68,7 @@ __all__ = [
"ws_delete_task",
"ws_duplicate_task",
"ws_list_tasks",
"ws_move_task",
"ws_postpone_task",
"ws_quick_complete_task",
"ws_reset_task",
@@ -105,6 +105,7 @@ TASK_UPDATE_FIELD_MAP = {
"nfc_tag_id": "nfc_tag_id",
"require_tag_scan": "require_tag_scan",
"allow_skip": "allow_skip",
"notify_enabled": "notify_enabled",
"reading_unit": "reading_unit",
"readings": "readings",
"consumes_parts": "consumes_parts",
@@ -200,6 +201,10 @@ _TASK_CREATE_SCHEMA: dict[Any, Any] = {
# #150: per-task skip lock — false hides Skip in the UIs and the
# coordinator refuses (WS + voice), so automations cannot skip either.
vol.Optional("allow_skip"): vol.Any(bool, None),
# #173: per-task notification mute — false = no reminders for this
# task (status changes, repeats, lead-time, bundles); the dashboard
# and entities still show it.
vol.Optional("notify_enabled"): vol.Any(bool, None),
# v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...).
vol.Optional("reading_unit"): vol.Any(vol.All(str, vol.Length(max=MAX_READING_UNIT_LENGTH)), None),
# #161 phase 2: reading slots [{id?, name, unit?}] — shape-validated
@@ -383,6 +388,9 @@ async def ws_create_task(
# #150: stored only when False — absence means skipping is allowed.
if msg.get("allow_skip") is False:
task_data["allow_skip"] = False
# #173: same shape — stored only when False (absence = notifications on).
if msg.get("notify_enabled") is False:
task_data["notify_enabled"] = False
# 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
@@ -492,6 +500,10 @@ _TASK_UPDATE_SCHEMA: dict[Any, Any] = {
# #150: per-task skip lock — false hides Skip in the UIs and the
# coordinator refuses (WS + voice), so automations cannot skip either.
vol.Optional("allow_skip"): vol.Any(bool, None),
# #173: per-task notification mute — false = no reminders for this
# task (status changes, repeats, lead-time, bundles); the dashboard
# and entities still show it.
vol.Optional("notify_enabled"): vol.Any(bool, None),
# v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...).
vol.Optional("reading_unit"): vol.Any(vol.All(str, vol.Length(max=MAX_READING_UNIT_LENGTH)), None),
# #161 phase 2: reading slots [{id?, name, unit?}] — shape-validated
@@ -612,6 +624,12 @@ async def ws_update_task(
task["allow_skip"] = False
else:
task.pop("allow_skip", None)
# #173: notify_enabled follows the same only-when-False rule.
if "notify_enabled" in msg:
if msg["notify_enabled"] is False:
task["notify_enabled"] = False
else:
task.pop("notify_enabled", None)
# The loop above copies values verbatim, which is wrong for part links:
# `task/create` validates them and `task/update` did not, so an edit could
@@ -925,3 +943,50 @@ async def ws_duplicate_task(
await async_persist_task(hass, entry, new_task)
connection.send_result(msg["id"], {"task_id": new_task["id"]})
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/task/move",
vol.Required("entry_id"): str,
vol.Required("task_id"): str,
vol.Required("target_entry_id"): str,
}
)
@require_write
@websocket_api.async_response
async def ws_move_task(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Move a task to another object — config, history, readings and trigger
state travel with it; the task gets a new reference number and its
entities are recreated under the target object (forum #23)."""
entry = _load_object_entry(hass, connection, msg)
if entry is None:
return
if msg["task_id"] not in entry.data.get(CONF_TASKS, {}):
connection.send_error(msg["id"], "not_found", "Task not found")
return
target = _load_object_entry(
hass, connection, {**msg, "entry_id": msg["target_entry_id"]}, not_found_message="Target object not found"
)
if target is None:
return
if target.entry_id == entry.entry_id:
connection.send_error(msg["id"], "invalid_target", "The task already belongs to that object")
return
if (target.data.get(CONF_OBJECT) or {}).get("archived_at"):
connection.send_error(msg["id"], "invalid_target", "The target object is archived")
return
from .tasks_persist import async_move_task
try:
await async_move_task(hass, entry, target, msg["task_id"])
except ValueError as err:
connection.send_error(msg["id"], "limit_reached", str(err))
return
await hass.config_entries.async_reload(entry.entry_id)
await hass.config_entries.async_reload(target.entry_id)
connection.send_result(msg["id"], {"task_id": msg["task_id"], "entry_id": target.entry_id})
@@ -223,3 +223,90 @@ async def async_update_task_simple(
new_data[CONF_TASKS] = new_tasks
hass.config_entries.async_update_entry(entry, data=new_data)
await hass.config_entries.async_reload(entry_id)
async def async_move_task(
hass: HomeAssistant,
source: ConfigEntry,
target: ConfigEntry,
task_id: str,
) -> None:
"""Move a task — config AND dynamic state — from one object entry to another.
What travels: the task's config (schedule, trigger, checklist, parts links,
slug, NFC tag …) and its Store state (history, last_performed, planned
due, adaptive config, phase cursor, checklist progress, trigger runtime
incl. counter baselines). Group memberships follow the task. What does
not: the reference number (``ref_no`` and the history entries' numbers —
the target object numbers it afresh on its next refresh) and document
links (documents belong to the source object; ``async_delete_task``
unlinks them). Part links keep an explicit ``entry_id`` so a link to one
of the source object's parts still resolves as a foreign-pool link.
Both entries are reloaded by the caller: the source drops the task's
entities, the target creates them under its own object slug.
Raises ValueError when the target is full.
"""
from copy import deepcopy
from ..const import MAX_TASKS_PER_OBJECT
from .tasks_crud import async_delete_task
task_data = deepcopy(dict(source.data[CONF_TASKS][task_id]))
task_data.pop("ref_no", None)
links = task_data.get("consumes_parts")
if isinstance(links, list):
task_data["consumes_parts"] = [
{**link, "entry_id": link.get("entry_id") or source.entry_id} if isinstance(link, dict) else link for link in links
]
src_rd = _get_runtime_data(hass, source.entry_id)
src_store = getattr(src_rd, "store", None) if src_rd else None
state = deepcopy(src_store.get_task_state(task_id)) if src_store is not None else {}
state.pop("next_history_ref", None)
for entry in state.get("history") or []:
if isinstance(entry, dict):
entry.pop("ref_no", None)
# Group memberships: snapshot, let the delete sweep them, re-add under the target.
from ..const import CONF_GROUPS
from ..helpers.global_options import get_global_entry
member_groups: list[str] = []
global_entry = get_global_entry(hass)
if global_entry is not None:
for gid, group in (dict(global_entry.options or global_entry.data).get(CONF_GROUPS) or {}).items():
if any(isinstance(r, dict) and r.get("task_id") == task_id for r in group.get("task_refs", [])):
member_groups.append(gid)
existing = target.data.get(CONF_TASKS, {})
if len(existing) >= MAX_TASKS_PER_OBJECT:
raise ValueError(f"The target object already has the maximum of {MAX_TASKS_PER_OBJECT} tasks")
await async_delete_task(hass, source, task_id)
new_data = dict(target.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, {}))
obj["task_ids"] = [*obj.get("task_ids", []), task_id]
new_data[CONF_OBJECT] = obj
hass.config_entries.async_update_entry(target, data=new_data)
tgt_rd = _get_runtime_data(hass, target.entry_id)
tgt_store = getattr(tgt_rd, "store", None) if tgt_rd else None
if tgt_store is not None:
tgt_store.put_task_state(task_id, state)
await tgt_store.async_save()
if member_groups and global_entry is not None:
options = dict(global_entry.options or global_entry.data)
groups = dict(options.get(CONF_GROUPS) or {})
for gid in member_groups:
group = groups.get(gid)
if group is None:
continue
groups[gid] = {**group, "task_refs": [*group.get("task_refs", []), {"entry_id": target.entry_id, "task_id": task_id}]}
options[CONF_GROUPS] = groups
hass.config_entries.async_update_entry(global_entry, options=options)