257 files
This commit is contained in:
@@ -27,6 +27,7 @@ from ..const import (
|
||||
)
|
||||
from ..helpers.aggregate import get_object_entries, get_runtime_data, get_store, is_object_entry, object_name, task_entity_id
|
||||
from ..helpers.aggregate import object_slug as aggregate_object_slug
|
||||
from ..helpers.parts import MAX_CONSUME_QUANTITY
|
||||
from ..helpers.phases import current_phase_summary
|
||||
from ..helpers.ws_errors import send_translated_error
|
||||
|
||||
@@ -66,7 +67,7 @@ USED_PARTS_FIELD: Final[vol.Any] = vol.Any(
|
||||
vol.Required("part_id"): ID_FIELD,
|
||||
vol.Optional("entry_id"): ID_FIELD,
|
||||
vol.Optional("quantity", default=1): vol.All(
|
||||
vol.Any(int, float), vol.Coerce(float), vol.Range(min=0.01, max=999)
|
||||
vol.Any(int, float), vol.Coerce(float), vol.Range(min=0.01, max=MAX_CONSUME_QUANTITY)
|
||||
),
|
||||
}
|
||||
)
|
||||
@@ -230,6 +231,10 @@ def _build_task_summary(
|
||||
"archived": task_data.get("archived_at") is not None,
|
||||
"archived_at": task_data.get("archived_at"),
|
||||
"archived_reason": task_data.get("archived_reason"),
|
||||
# #193: this task's own pause (a paused OBJECT is on the object).
|
||||
"paused": task_data.get("paused_at") is not None,
|
||||
"paused_at": task_data.get("paused_at"),
|
||||
"paused_until": task_data.get("paused_until"),
|
||||
"responsible_user_id": task_data.get("responsible_user_id"),
|
||||
"assignee_pool": task_data.get("assignee_pool", []),
|
||||
"required_completion_fields": task_data.get("required_completion_fields", []),
|
||||
@@ -808,9 +813,11 @@ def async_register_commands(hass: HomeAssistant) -> None:
|
||||
ws_duplicate_task,
|
||||
ws_list_tasks,
|
||||
ws_move_task,
|
||||
ws_pause_task,
|
||||
ws_postpone_task,
|
||||
ws_quick_complete_task,
|
||||
ws_reset_task,
|
||||
ws_resume_task,
|
||||
ws_set_task_phase,
|
||||
ws_skip_task,
|
||||
ws_snooze_task,
|
||||
@@ -848,6 +855,8 @@ def async_register_commands(hass: HomeAssistant) -> None:
|
||||
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_pause_task)
|
||||
websocket_api.async_register_command(hass, ws_resume_task)
|
||||
websocket_api.async_register_command(hass, ws_list_tasks)
|
||||
websocket_api.async_register_command(hass, ws_task_history)
|
||||
websocket_api.async_register_command(hass, ws_complete_task)
|
||||
|
||||
@@ -25,7 +25,6 @@ from ..const import (
|
||||
CONF_ADVANCED_COMPLETION_ACTIONS,
|
||||
CONF_ADVANCED_ENVIRONMENTAL,
|
||||
CONF_ADVANCED_GROUPS,
|
||||
CONF_ADVANCED_PREDICTIONS,
|
||||
CONF_ADVANCED_SCHEDULE_TIME,
|
||||
CONF_ADVANCED_SEASONAL,
|
||||
CONF_ARCHIVE_ONEOFF_DAYS,
|
||||
@@ -68,6 +67,7 @@ from ..const import (
|
||||
CONF_PANEL_ENABLED,
|
||||
CONF_PANEL_TITLE,
|
||||
CONF_PART_SEARCH_URL_TEMPLATE,
|
||||
CONF_PARTS_COST_MODE,
|
||||
CONF_QUIET_HOURS_ENABLED,
|
||||
CONF_QUIET_HOURS_END,
|
||||
CONF_QUIET_HOURS_START,
|
||||
@@ -208,7 +208,6 @@ def _build_full_settings(
|
||||
return {
|
||||
"features": {
|
||||
"adaptive": _opt(options, CONF_ADVANCED_ADAPTIVE),
|
||||
"predictions": _opt(options, CONF_ADVANCED_PREDICTIONS),
|
||||
"seasonal": _opt(options, CONF_ADVANCED_SEASONAL),
|
||||
"environmental": _opt(options, CONF_ADVANCED_ENVIRONMENTAL),
|
||||
"budget": _opt(options, CONF_ADVANCED_BUDGET),
|
||||
@@ -326,6 +325,8 @@ def _build_full_settings(
|
||||
"yearly": _opt(options, CONF_BUDGET_YEARLY),
|
||||
"alerts_enabled": _opt(options, CONF_BUDGET_ALERTS_ENABLED),
|
||||
"alert_threshold_pct": _opt(options, CONF_BUDGET_ALERT_THRESHOLD),
|
||||
# #104: purchase | use — when spare parts count as spending.
|
||||
"parts_cost_mode": _opt(options, CONF_PARTS_COST_MODE),
|
||||
**_currency_block(options),
|
||||
},
|
||||
# Vacation mode (v1.2.0). Mirror the active flag so the panel can
|
||||
@@ -783,7 +784,10 @@ def sanitize_settings_input(settings_input: dict[str, Any]) -> tuple[dict[str, A
|
||||
# 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 HOME_TYPES, NOTIFICATION_TITLE_STYLES, NOTIFY_COMPLETED_MODES
|
||||
from ..const import HOME_TYPES, NOTIFICATION_TITLE_STYLES, NOTIFY_COMPLETED_MODES, PARTS_COST_MODES
|
||||
|
||||
if CONF_PARTS_COST_MODE in filtered and filtered[CONF_PARTS_COST_MODE] not in PARTS_COST_MODES:
|
||||
del filtered[CONF_PARTS_COST_MODE]
|
||||
|
||||
if CONF_HOME_TYPE in filtered and filtered[CONF_HOME_TYPE] not in HOME_TYPES:
|
||||
del filtered[CONF_HOME_TYPE]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -461,6 +461,19 @@ async def ws_update_object(
|
||||
# 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
|
||||
swap_report = None
|
||||
new_device = msg.get("ha_device_id")
|
||||
if new_device and new_device != obj.get("ha_device_id"):
|
||||
# A new device for this object (the appliance was replaced, or
|
||||
# re-paired and came back under a new id): its sensor triggers,
|
||||
# completion actions and adopted-task fingerprints follow it.
|
||||
from ..helpers.device_swap import move_tasks_to_device, old_device_ids
|
||||
|
||||
tasks = dict(new_data.get(CONF_TASKS) or {})
|
||||
moved_tasks, swap_report = move_tasks_to_device(
|
||||
hass, tasks, old_device_ids(obj, tasks.values(), new_device), new_device
|
||||
)
|
||||
new_data[CONF_TASKS] = moved_tasks
|
||||
for key in ("ha_device_id", "parent_entry_id"):
|
||||
if key in msg and msg[key] != obj.get(key):
|
||||
obj[key] = msg[key]
|
||||
@@ -475,7 +488,10 @@ async def ws_update_object(
|
||||
if device_link_changed:
|
||||
hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
result: dict[str, Any] = {"success": True}
|
||||
if swap_report is not None:
|
||||
result["device_swap"] = swap_report.as_dict()
|
||||
connection.send_result(msg["id"], result)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
@@ -902,6 +918,10 @@ async def ws_resume_object(
|
||||
vol.Required("type"): "maintenance_supporter/object/replace",
|
||||
vol.Required("entry_id"): ID_FIELD,
|
||||
vol.Optional("name"): vol.Any(vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)), None),
|
||||
# The new unit's device: omitted = the same device as the old unit
|
||||
# (a controller or smart plug that stays), an id = the new unit's own
|
||||
# device (the wiring follows it), null = none yet.
|
||||
vol.Optional("ha_device_id"): vol.Any(ID_FIELD, None),
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@@ -933,6 +953,8 @@ async def ws_replace_object(
|
||||
return
|
||||
|
||||
name = (msg.get("name") or "").strip() or str(src_obj.get(CONF_OBJECT_NAME, "")).strip() or "Object"
|
||||
if not _validate_device_link(hass, connection, msg, self_entry_id=None):
|
||||
return
|
||||
|
||||
new_obj = deepcopy(dict(src_obj))
|
||||
new_obj["id"] = uuid4().hex
|
||||
@@ -972,6 +994,20 @@ async def ws_replace_object(
|
||||
new_tasks[task_id] = task
|
||||
new_obj["task_ids"].append(task_id)
|
||||
|
||||
# The new unit is usually a new device in Home Assistant, not the old
|
||||
# one: carried over verbatim, the successor kept watching the retired
|
||||
# machine's sensors and pressing its reset button.
|
||||
swap_report = None
|
||||
if "ha_device_id" in msg:
|
||||
new_device = msg["ha_device_id"]
|
||||
if new_device and new_device != src_obj.get("ha_device_id"):
|
||||
from ..helpers.device_swap import move_tasks_to_device, old_device_ids
|
||||
|
||||
new_tasks, swap_report = move_tasks_to_device(
|
||||
hass, new_tasks, old_device_ids(src_obj, new_tasks.values(), new_device), new_device
|
||||
)
|
||||
new_obj["ha_device_id"] = new_device
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": "websocket"},
|
||||
@@ -1029,7 +1065,10 @@ async def ws_replace_object(
|
||||
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})
|
||||
reply: dict[str, Any] = {"entry_id": new_entry_id}
|
||||
if swap_report is not None:
|
||||
reply["device_swap"] = swap_report.as_dict()
|
||||
connection.send_result(msg["id"], reply)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
|
||||
@@ -43,8 +43,11 @@ _PART_FIELDS_SCHEMA: VolDictType = {
|
||||
vol.Optional("notes"): vol.Any(str, None),
|
||||
vol.Optional("unit"): vol.Any(str, None),
|
||||
vol.Optional("cost"): vol.Any(int, float, None),
|
||||
vol.Optional("reorder_threshold"): vol.Any(int, None),
|
||||
# Decimal thresholds (#98: "0.5 cans") — the schema said int and refused
|
||||
# the 0.5 the panel sends, although normalize_part accepts it.
|
||||
vol.Optional("reorder_threshold"): vol.Any(int, float, None),
|
||||
vol.Optional("restock_quantity"): vol.Any(int, float, None),
|
||||
vol.Optional("package_size"): vol.Any(int, float, None),
|
||||
vol.Optional("auto_buy_task"): bool,
|
||||
vol.Optional("doc_id"): vol.Any(ID_FIELD, None),
|
||||
# Initial / edited stock travels WITH the definition for dialog simplicity,
|
||||
@@ -236,7 +239,7 @@ async def ws_delete_part(
|
||||
# Either a relative delta (may be negative for corrections) or an
|
||||
# absolute count — exactly one.
|
||||
vol.Optional("delta"): vol.All(vol.Any(int, float), vol.Coerce(float), vol.Range(min=-MAX_PART_STOCK, max=MAX_PART_STOCK)),
|
||||
vol.Optional("absolute"): vol.All(int, vol.Range(min=0, max=MAX_PART_STOCK)),
|
||||
vol.Optional("absolute"): vol.All(vol.Any(int, float), vol.Coerce(float), vol.Range(min=0, max=MAX_PART_STOCK)),
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
|
||||
@@ -30,6 +30,8 @@ from .tasks_lifecycle import (
|
||||
_is_recurring_schedule,
|
||||
ws_archive_task,
|
||||
ws_list_tasks,
|
||||
ws_pause_task,
|
||||
ws_resume_task,
|
||||
ws_task_history,
|
||||
ws_unarchive_task,
|
||||
)
|
||||
@@ -70,9 +72,11 @@ __all__ = [
|
||||
"ws_duplicate_task",
|
||||
"ws_list_tasks",
|
||||
"ws_move_task",
|
||||
"ws_pause_task",
|
||||
"ws_postpone_task",
|
||||
"ws_quick_complete_task",
|
||||
"ws_reset_task",
|
||||
"ws_resume_task",
|
||||
"ws_set_task_phase",
|
||||
"ws_skip_task",
|
||||
"ws_snooze_task",
|
||||
|
||||
@@ -21,6 +21,7 @@ from ..const import (
|
||||
MAX_TIMESTAMP_LENGTH,
|
||||
)
|
||||
from ..helpers.completion_photos import MAX_COMPLETION_PHOTOS, normalize_photo_doc_ids
|
||||
from ..helpers.parts import MAX_PART_STOCK
|
||||
from ..helpers.ws_errors import send_exception_error, send_translated_error
|
||||
from ..models.maintenance_task import MaintenanceTask
|
||||
from . import (
|
||||
@@ -106,7 +107,7 @@ def _refuse_too_early(connection: websocket_api.ActiveConnection, msg: dict[str,
|
||||
vol.Optional("reading_values"): READING_VALUES_FIELD,
|
||||
# Spare parts: on an auto-created "buy" task, how many units were
|
||||
# actually bought (dialog override of the part's restock_quantity).
|
||||
vol.Optional("restock_quantity"): vol.Any(vol.All(vol.Any(int, float), vol.Coerce(float), vol.Range(min=0.01, max=9999)), None),
|
||||
vol.Optional("restock_quantity"): vol.Any(vol.All(vol.Any(int, float), vol.Coerce(float), vol.Range(min=0.01, max=MAX_PART_STOCK)), None),
|
||||
# #99: the parts actually used on THIS completion. An explicit list
|
||||
# (even an empty one) REPLACES the task's automatic consumes_parts
|
||||
# deduction; omitting the key keeps the automatic behaviour.
|
||||
@@ -459,7 +460,8 @@ async def ws_snooze_task(
|
||||
due-soon/overdue/triggered reminders for ``snooze_duration_hours`` — it does
|
||||
not change the task's schedule or state.
|
||||
"""
|
||||
from ..const import DOMAIN, NOTIFICATION_MANAGER_KEY
|
||||
from ..const import CONF_SNOOZE_DURATION_HOURS, DOMAIN, NOTIFICATION_MANAGER_KEY
|
||||
from ..helpers.global_options import global_option
|
||||
|
||||
if _load_object_task(hass, connection, msg) is None:
|
||||
return
|
||||
@@ -468,8 +470,11 @@ async def ws_snooze_task(
|
||||
if nm is None:
|
||||
send_translated_error(connection, msg["id"], "unavailable", "Notifications not configured", translation_key="notify_not_configured")
|
||||
return
|
||||
nm.snooze_task(msg["entry_id"], msg["task_id"])
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
until = nm.snooze_task(msg["entry_id"], msg["task_id"])
|
||||
# #193: the panel names the duration — "Snoozed" alone left users
|
||||
# wondering what happened and for how long.
|
||||
hours = global_option(hass, CONF_SNOOZE_DURATION_HOURS)
|
||||
connection.send_result(msg["id"], {"success": True, "hours": hours, "snoozed_until": until.isoformat()})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
|
||||
@@ -249,6 +249,10 @@ async def ws_update_history_entry(
|
||||
patched["used_parts"] = enriched
|
||||
else:
|
||||
patched.pop("used_parts", None)
|
||||
# #104: the entry's parts value follows the corrected selection.
|
||||
from ..helpers.parts_cost import refresh_parts_cost
|
||||
|
||||
refresh_parts_cost(patched)
|
||||
|
||||
history[target_index] = patched
|
||||
store.set_history(task_id, history)
|
||||
|
||||
@@ -12,17 +12,20 @@ from homeassistant.util import dt as dt_util
|
||||
from ..const import (
|
||||
ARCHIVE_REASON_MANUAL,
|
||||
CONF_TASKS,
|
||||
MAX_DATE_LENGTH,
|
||||
)
|
||||
from ..helpers.aggregate import get_coordinator_data, get_store, object_name
|
||||
from ..helpers.entry_tasks import write_task
|
||||
from ..helpers.pause import clear_cycle_modifiers, reanchor_recurring_task
|
||||
from ..helpers.permissions import require_write
|
||||
from ..helpers.ws_errors import send_translated_error
|
||||
from . import (
|
||||
ID_FIELD,
|
||||
_build_task_summary,
|
||||
_get_merged_tasks,
|
||||
_get_object_entries,
|
||||
_load_object_task,
|
||||
_parse_iso_date,
|
||||
)
|
||||
|
||||
|
||||
@@ -80,6 +83,109 @@ async def ws_archive_task(
|
||||
connection.send_result(msg["id"], {"success": True, "archived_at": td["archived_at"]})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/task/pause",
|
||||
vol.Required("entry_id"): ID_FIELD,
|
||||
vol.Required("task_id"): ID_FIELD,
|
||||
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_task(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Pause ONE task (#193): frozen schedule, no reminders, until resumed.
|
||||
|
||||
The object pause, per task — for a filter that comes out of use for a
|
||||
while (items used in rotation): resuming starts a fresh cycle, so the
|
||||
interval counts from the day it goes back in. ``until`` (optional future
|
||||
date) resumes it on that day.
|
||||
"""
|
||||
ctx = _load_object_task(hass, connection, msg)
|
||||
if ctx is None:
|
||||
return
|
||||
entry, _rd, task = ctx
|
||||
task_id = msg["task_id"]
|
||||
td = dict(task)
|
||||
if td.get("archived_at") is not None:
|
||||
send_translated_error(connection, msg["id"], "archived", "An archived task cannot be paused", translation_key="archived_task_cannot_pause")
|
||||
return
|
||||
if td.get("paused_at") is not None:
|
||||
connection.send_error(msg["id"], "already_paused", "Task already paused")
|
||||
return
|
||||
|
||||
until = msg.get("until")
|
||||
if until:
|
||||
until_date = _parse_iso_date(connection, msg["id"], until, field="until")
|
||||
if until_date is None:
|
||||
return
|
||||
if until_date <= dt_util.now().date():
|
||||
send_translated_error(connection, msg["id"], "invalid_date", "until must be a future date", translation_key="until_in_past")
|
||||
return
|
||||
|
||||
td["paused_at"] = dt_util.now().isoformat()
|
||||
td["paused_until"] = until or None
|
||||
write_task(hass, entry, task_id, td)
|
||||
# Reload: a sensor task's triggers tear down (paused = inert) and every
|
||||
# per-task entity repaints as paused.
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
connection.send_result(msg["id"], {"success": True, "paused_at": td["paused_at"], "paused_until": td["paused_until"]})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/task/resume",
|
||||
vol.Required("entry_id"): ID_FIELD,
|
||||
vol.Required("task_id"): ID_FIELD,
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_resume_task(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""End a task's pause: a recurring task restarts a fresh cycle today."""
|
||||
ctx = _load_object_task(hass, connection, msg)
|
||||
if ctx is None:
|
||||
return
|
||||
entry, _rd, td = ctx
|
||||
task_id = msg["task_id"]
|
||||
if td.get("paused_at") is None:
|
||||
connection.send_error(msg["id"], "not_paused", "Task is not paused")
|
||||
return
|
||||
|
||||
# The unarchive order: the Store anchor first (its save is the only
|
||||
# await), then the static dict from a FRESH read, so a writer landing
|
||||
# during the disk write cannot be reverted by a stale copy.
|
||||
store = get_store(hass, entry.entry_id)
|
||||
recurring = td.get("archived_at") is None and _is_recurring_schedule(td)
|
||||
today_iso = dt_util.now().date().isoformat()
|
||||
if recurring and store is not None:
|
||||
reanchor_recurring_task(task_id, store=store, today_iso=today_iso)
|
||||
await store.async_save()
|
||||
fresh = entry.data.get(CONF_TASKS, {}).get(task_id)
|
||||
if fresh is None:
|
||||
connection.send_error(msg["id"], "not_found", "Task not found")
|
||||
return
|
||||
td = dict(fresh)
|
||||
td.pop("paused_at", None)
|
||||
td.pop("paused_until", None)
|
||||
if recurring and store is None:
|
||||
reanchor_recurring_task(task_id, store=None, today_iso=today_iso, task_data=td)
|
||||
elif recurring:
|
||||
# The Store holds the fresh anchor; scrub the static shadow too.
|
||||
clear_cycle_modifiers(td)
|
||||
write_task(hass, entry, task_id, td)
|
||||
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/task/unarchive",
|
||||
|
||||
@@ -23,10 +23,17 @@ from ..helpers.entry_tasks import insert_new_task
|
||||
from ..helpers.global_options import get_default_warning_days
|
||||
from ..helpers.sanitize import cap_task_fields
|
||||
from ..helpers.schedule import (
|
||||
KIND_INTERVAL,
|
||||
KIND_MANUAL,
|
||||
KIND_ONE_TIME,
|
||||
Schedule,
|
||||
normalize_task_storage,
|
||||
)
|
||||
|
||||
_TIME_BASED = "time_based"
|
||||
# The flat service schedule_type → the Schedule kind it means.
|
||||
_FLAT_TO_KIND = {_TIME_BASED: KIND_INTERVAL, KIND_ONE_TIME: KIND_ONE_TIME, KIND_MANUAL: KIND_MANUAL}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -46,6 +53,53 @@ def _checked_due_date(value: Any) -> str:
|
||||
return parsed.isoformat()
|
||||
|
||||
|
||||
def _check_schedule_request(
|
||||
schedule_type: str | None,
|
||||
*,
|
||||
interval_days: Any,
|
||||
schedule: dict[str, Any] | None,
|
||||
stored_kind: str | None = None,
|
||||
) -> None:
|
||||
"""Refuse a recurrence request that contradicts itself.
|
||||
|
||||
The storage model resolves a contradiction by picking one side, so the
|
||||
services used to report success for a task that was quietly something
|
||||
else (bug audit 2026-09-29): ``time_based`` without an interval made a
|
||||
manual task (a weekday or one-time task lost its schedule), ``manual``
|
||||
with ``interval_days`` an interval task, ``one_time`` with an interval
|
||||
``schedule`` a one-time task without a date. ``stored_kind`` is the
|
||||
task's current kind on an edit — an interval task switched to
|
||||
``time_based`` keeps its interval. Raises ValueError (the services report
|
||||
it as invalid input).
|
||||
"""
|
||||
if schedule_type is None:
|
||||
return
|
||||
if schedule:
|
||||
parsed = Schedule.from_dict(schedule)
|
||||
kind = parsed.kind
|
||||
if parsed.is_calendar_kind:
|
||||
if schedule_type != _TIME_BASED:
|
||||
raise ValueError(f"schedule_type {schedule_type!r} contradicts the {kind!r} schedule (use time_based)")
|
||||
elif _FLAT_TO_KIND.get(schedule_type) != kind:
|
||||
raise ValueError(f"schedule_type {schedule_type!r} contradicts the schedule's kind {kind!r}")
|
||||
return
|
||||
if schedule_type == KIND_MANUAL and interval_days is not None:
|
||||
raise ValueError("A manual task has no interval — leave interval_days out")
|
||||
if schedule_type == _TIME_BASED and interval_days is None and stored_kind != KIND_INTERVAL:
|
||||
raise ValueError("A time-based task needs interval_days")
|
||||
|
||||
|
||||
def _implied_schedule_type(*, interval_days: Any, due_date: Any, schedule: dict[str, Any] | None) -> str:
|
||||
"""What an ``add_task`` call without ``schedule_type`` asks for: the kind
|
||||
its other fields describe (a bare name stays a manual task, as before)."""
|
||||
if schedule:
|
||||
kind = Schedule.from_dict(schedule).kind
|
||||
return kind if kind in (KIND_ONE_TIME, KIND_MANUAL) else _TIME_BASED
|
||||
if due_date:
|
||||
return KIND_ONE_TIME
|
||||
return _TIME_BASED if interval_days is not None else KIND_MANUAL
|
||||
|
||||
|
||||
async def async_persist_task(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
@@ -77,7 +131,7 @@ async def async_create_task_simple(
|
||||
entry_id: str,
|
||||
name: str,
|
||||
task_type: str = "custom",
|
||||
schedule_type: str = "time_based",
|
||||
schedule_type: str | None = None,
|
||||
interval_days: int | None = None,
|
||||
interval_unit: str = "days",
|
||||
due_date: str | None = None,
|
||||
@@ -94,7 +148,9 @@ async def async_create_task_simple(
|
||||
the panel / card dialogs or the ``task/create`` WS command.
|
||||
|
||||
Raises ValueError if the entry_id is not a maintenance object, the name
|
||||
is empty or the due date is not a date.
|
||||
is empty, the due date is not a date or the recurrence contradicts itself
|
||||
(:func:`_check_schedule_request`). Without ``schedule_type`` the kind the
|
||||
other fields describe is created — a bare name stays a manual task.
|
||||
|
||||
``warning_days`` defaults to the integration-wide setting like every
|
||||
other create path — it was the bare constant 7 here (bug audit
|
||||
@@ -112,6 +168,12 @@ async def async_create_task_simple(
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("Name must not be empty")
|
||||
if schedule_type is None:
|
||||
schedule_type = _implied_schedule_type(interval_days=interval_days, due_date=due_date, schedule=schedule)
|
||||
else:
|
||||
_check_schedule_request(schedule_type, interval_days=interval_days, schedule=schedule)
|
||||
if schedule_type == KIND_ONE_TIME and not due_date and not (schedule and Schedule.from_dict(schedule).due_date):
|
||||
raise ValueError("A one-time task needs a due_date")
|
||||
task_id = uuid4().hex
|
||||
task_data: dict[str, Any] = {
|
||||
"id": task_id,
|
||||
@@ -191,6 +253,13 @@ async def async_update_task_simple(
|
||||
raise ValueError(f"No task {task_id!r} in {entry.title!r}")
|
||||
|
||||
task = dict(new_tasks[task_id])
|
||||
stored_kind = Schedule.parse(new_tasks[task_id]).kind
|
||||
_check_schedule_request(
|
||||
updates.get("schedule_type"),
|
||||
interval_days=updates.get("interval_days"),
|
||||
schedule=updates.get("schedule"),
|
||||
stored_kind=stored_kind,
|
||||
)
|
||||
if updates.get("due_date") is not None:
|
||||
updates = {**updates, "due_date": _checked_due_date(updates["due_date"])}
|
||||
for key in _UPDATABLE_FLAT_FIELDS:
|
||||
@@ -222,6 +291,21 @@ async def async_update_task_simple(
|
||||
# task/update — the flat fields rebuild the schedule (bug audit
|
||||
# 2026-09-26).
|
||||
task.pop("schedule", None)
|
||||
new_kind = updates.get("schedule_type")
|
||||
if (
|
||||
new_kind in (KIND_MANUAL, KIND_ONE_TIME)
|
||||
and not updates.get("schedule")
|
||||
and (new_kind != stored_kind or new_kind == KIND_MANUAL)
|
||||
):
|
||||
# A switch to manual / one-time: the stored interval rode along into
|
||||
# the flat overlay and rebuilt an interval, so the switch silently did
|
||||
# nothing (audit 2026-09-29). The old recurrence goes; a one-time task
|
||||
# needs its date.
|
||||
if new_kind == KIND_ONE_TIME and not task.get("due_date"):
|
||||
raise ValueError("A one-time task needs a due_date")
|
||||
task.pop("schedule", None)
|
||||
for key in ("interval_days", "interval_unit", "interval_anchor"):
|
||||
task.pop(key, None)
|
||||
|
||||
# The service cannot change the completion action, so the stored one keeps
|
||||
# the user it runs as — dropping ``configured_by`` here made an operator's
|
||||
|
||||
@@ -93,6 +93,7 @@ _TRIGGER_REQUIRED_FIELDS: dict[str, list[str]] = {
|
||||
"counter": ["trigger_target_value"],
|
||||
"state_change": [],
|
||||
"runtime": ["trigger_runtime_hours"],
|
||||
"due_date": [], # trigger_days_before defaults to 0 = on the date itself
|
||||
"compound": [], # conditions validated separately
|
||||
}
|
||||
|
||||
@@ -120,6 +121,8 @@ _TRIGGER_ALLOWED_KEYS: set[str] = {
|
||||
"trigger_from_state",
|
||||
"trigger_to_state",
|
||||
"trigger_target_changes",
|
||||
# due_date
|
||||
"trigger_days_before",
|
||||
# compound
|
||||
"compound_logic",
|
||||
"conditions",
|
||||
@@ -273,6 +276,7 @@ _OPTIONAL_VALUE_FIELDS: tuple[str, ...] = (
|
||||
"trigger_baseline_value",
|
||||
"trigger_delta_mode",
|
||||
"trigger_runtime_hours",
|
||||
"trigger_days_before",
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user