Updated apps

This commit is contained in:
2026-07-20 22:52:35 -04:00
parent 28a8cb98f6
commit a0c3271743
1164 changed files with 94781 additions and 6892 deletions
@@ -170,6 +170,9 @@ def _build_task_summary(
else None
),
"trigger_config": trigger_config,
# Battery Fleet: marks the single aggregate task so the detail view
# renders the battery section instead of the generic trigger card.
"battery_fleet_task": task_data.get("battery_fleet_task", False),
"trigger_entity_info": trigger_entity_info,
"trigger_entity_infos": trigger_entity_infos,
"checklist": task_data.get("checklist", []),
@@ -423,10 +426,16 @@ def async_register_commands(hass: HomeAssistant) -> None:
ws_seasonal_overrides,
ws_set_environmental_entity,
)
from .battery_fleet import (
ws_battery_fleet_mark_replaced,
ws_battery_fleet_overview,
ws_battery_fleet_setup,
)
from .dashboard import (
ws_get_budget_status,
ws_get_settings,
ws_get_statistics,
ws_schedule_preview,
ws_subscribe,
ws_test_notification,
ws_update_global_settings,
@@ -445,6 +454,10 @@ def async_register_commands(hass: HomeAssistant) -> None:
ws_get_groups,
ws_update_group,
)
from .integration_setups import (
ws_adopt_integration_setups,
ws_discover_integration_setups,
)
from .io import (
ws_batch_generate_qr,
ws_export_csv,
@@ -545,12 +558,18 @@ def async_register_commands(hass: HomeAssistant) -> None:
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_schedule_preview)
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_discover_problem_sensors)
websocket_api.async_register_command(hass, ws_adopt_problem_sensors)
websocket_api.async_register_command(hass, ws_battery_fleet_overview)
websocket_api.async_register_command(hass, ws_battery_fleet_setup)
websocket_api.async_register_command(hass, ws_battery_fleet_mark_replaced)
websocket_api.async_register_command(hass, ws_discover_integration_setups)
websocket_api.async_register_command(hass, ws_adopt_integration_setups)
websocket_api.async_register_command(hass, ws_list_saved_views)
websocket_api.async_register_command(hass, ws_save_saved_view)
websocket_api.async_register_command(hass, ws_delete_saved_view)
@@ -0,0 +1,80 @@
"""WebSocket commands for the Battery Fleet.
``battery_fleet/overview`` (read) returns the live aggregated view — low now,
grouped shopping needs, forecast — for the fleet task's detail. ``setup``
(admin write) creates the fleet object + type-parts + the single task.
"""
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 DOMAIN, MAX_ENTITY_ID_LENGTH
from ..helpers.battery_fleet import compute_overview, has_batteries, has_battery_notes
from ..helpers.battery_fleet_setup import (
async_mark_replaced,
async_setup_battery_fleet,
find_fleet_entry,
fleet_task_trigger_ok,
)
from ..helpers.permissions import require_write
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/battery_fleet/overview"})
@websocket_api.async_response
async def ws_battery_fleet_overview(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]) -> None:
"""Return the live fleet overview (low now, needs grouped, forecast)."""
ov = compute_overview(hass)
fleet = find_fleet_entry(hass)
connection.send_result(
msg["id"],
{
"available": has_batteries(hass),
"has_battery_notes": has_battery_notes(hass),
"configured": fleet is not None,
# False when the fleet task was deleted or its trigger was wiped
# (issue #106) — the detail section offers a one-click repair,
# which re-runs the idempotent setup.
"task_ok": fleet is not None and fleet_task_trigger_ok(fleet),
"entry_id": fleet.entry_id if fleet else None,
"total": ov.total,
"low": ov.low,
"soon": ov.soon,
"needs_now": dict(ov.needs_now),
"needs_soon": dict(ov.needs_soon),
"types": ov.types,
},
)
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/battery_fleet/setup"})
@require_write
@websocket_api.async_response
async def ws_battery_fleet_setup(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]) -> None:
"""Create (or reconcile) the Battery Fleet object + type-parts + task."""
if not has_batteries(hass):
connection.send_error(msg["id"], "not_available", "No battery devices found")
return
result = await async_setup_battery_fleet(hass)
connection.send_result(msg["id"], result)
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/battery_fleet/mark_replaced",
# battery_plus entity_ids to mark; omit to mark ALL currently low.
vol.Optional("entity_ids"): [vol.All(str, vol.Length(max=MAX_ENTITY_ID_LENGTH))],
}
)
@require_write
@websocket_api.async_response
async def ws_battery_fleet_mark_replaced(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Mark batteries replaced (press their button + consume the type-parts)."""
result = await async_mark_replaced(hass, msg.get("entity_ids"))
connection.send_result(msg["id"], result)
@@ -337,6 +337,68 @@ async def ws_subscribe(
_forward_update()
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/schedule/preview",
# A DRAFT schedule in Schedule.to_dict form — validated leniently by
# Schedule.from_dict (unknown kinds sanitize to manual → empty result).
vol.Required("schedule"): dict,
vol.Optional("last_performed"): vol.Any(str, None),
vol.Optional("times_performed", default=0): vol.All(int, vol.Range(min=0, max=100000)),
vol.Optional("count", default=3): vol.All(int, vol.Range(min=1, max=10)),
}
)
@websocket_api.async_response
async def ws_schedule_preview(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Next N occurrences of a draft schedule — computed by the REAL engine.
Powers the task dialog's live "next dates" preview (#83 roadmap item).
Stateless and read-only: instantiates Schedule.from_dict and iterates
next_due(), simulating an ON-TIME completion per step (last_performed /
last_planned_due advance, times_performed increments) — so completion-
anchored intervals, calendar kinds, season windows, business-day rolls,
±offsets and finite series all advance exactly as the engine would.
Never a frontend reimplementation (the #103 drift lesson).
"""
from datetime import date as date_cls
from homeassistant.util import dt as dt_util
from ..helpers.schedule import Schedule, preview_occurrences
lp: date_cls | None = None
raw_lp = msg.get("last_performed")
if raw_lp:
try:
lp = date_cls.fromisoformat(raw_lp)
except ValueError:
connection.send_error(msg["id"], "invalid_date", "Invalid last_performed (expected YYYY-MM-DD)")
return
today = dt_util.now().date()
try:
sched = Schedule.from_dict(msg["schedule"])
dates, series_ended = preview_occurrences(
sched,
last_performed=lp,
times_performed=int(msg.get("times_performed", 0)),
today=today,
count=int(msg.get("count", 3)),
)
except (TypeError, ValueError) as err:
connection.send_error(msg["id"], "invalid_input", f"Invalid schedule: {err}")
return
connection.send_result(
msg["id"],
{"occurrences": [d.isoformat() for d in dates], "series_ended": series_ended},
)
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/budget_status"})
@websocket_api.async_response
async def ws_get_budget_status(
@@ -0,0 +1,188 @@
"""WebSocket commands for integration-aware suggested setups (roadmap).
``integration_setups/discover`` lists devices of catalogued integrations
(helpers/integration_signatures — every signature verified against the
integration's source) whose consumable entities can back maintenance tasks;
``integration_setups/adopt`` creates the object (or extends the existing one on
that device) with the tasks and their sensor triggers PRE-WIRED. Discovery is
read; adoption is write, mirroring problem-sensor adoption.
"""
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 CONF_OBJECT, CONF_TASKS, DOMAIN, MAX_ID_LENGTH, MAX_NAME_LENGTH
from ..helpers.integration_signatures import (
SIGNATURES,
build_setup_trigger,
discover_integration_setups,
)
from ..helpers.permissions import require_write
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/integration_setups/discover"})
@websocket_api.async_response
async def ws_discover_integration_setups(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""List suggested maintenance setups for catalogued integrations."""
connection.send_result(msg["id"], {"setups": discover_integration_setups(hass)})
_SELECTION_SCHEMA = vol.Schema(
{
vol.Required("device_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
# Existing target object; omit to create a fresh object for the device.
vol.Optional("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Optional("object_name"): vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)),
# Subset of suggested task names to adopt; omit = all suggested.
vol.Optional("task_names"): vol.All(
[vol.All(str, vol.Length(max=MAX_NAME_LENGTH))], vol.Length(max=20)
),
# Optional counting start values per task name (#102): "the last
# service was at reading X". Only applied to usage_delta duties —
# the delta then counts from X instead of the adoption reading, so
# an interval that already elapsed comes due immediately.
vol.Optional("baselines"): {
vol.All(str, vol.Length(max=MAX_NAME_LENGTH)): vol.All(
vol.Coerce(float), vol.Range(min=0)
)
},
}
)
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/integration_setups/adopt",
vol.Required("selections"): vol.All([_SELECTION_SCHEMA], vol.Length(min=1, max=50)),
}
)
@require_write
@websocket_api.async_response
async def ws_adopt_integration_setups(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Create sensor-wired maintenance tasks for the selected suggestions."""
from ..export import object_entries
from ..helpers.i18n import normalize_language
from ..templates import localize_template_text
from .objects import async_create_object
from .tasks_persist import async_persist_task
lang = normalize_language(hass)
# Re-run discovery server-side: the wiring (entities, thresholds) comes
# from the verified catalog, never from the client.
setups = {s["device_id"]: s for s in discover_integration_setups(hass)}
tasks_created = 0
objects_created = 0
errors: list[dict[str, str]] = []
for sel in msg["selections"]:
device_id = sel["device_id"]
setup = setups.get(device_id)
if setup is None:
errors.append({"device_id": device_id, "reason": "no suggestion for this device"})
continue
wanted = set(sel.get("task_names") or [t["task_name"] for t in setup["tasks"]])
# Keyed by (task_name, direction): one integration can ship a task name
# in two directions (LG ThinQ filter: hours vs percent), and the trigger
# must be built from the signature matching the discovered direction.
sig_by_key = {
(s.task_name, s.direction): s for s in SIGNATURES[setup["integration"]].tasks
}
created_entry_id: str | None = None
try:
entry_id = sel.get("entry_id") or setup["suggested_entry_id"]
if not entry_id:
entry_id = await async_create_object(
hass,
name=sel.get("object_name") or setup["suggested_object_name"],
ha_device_id=device_id,
)
created_entry_id = entry_id
objects_created += 1
entry = hass.config_entries.async_get_entry(entry_id)
if entry is None or entry.domain != DOMAIN:
errors.append({"device_id": device_id, "reason": "target object not found"})
continue
# #105: adopting into a user-picked existing object that isn't
# device-bound yet — bind it, so model/sibling gates work and
# future discovery suggests this object instead of a new one.
# (Objects reached via suggested_entry_id are bound by definition;
# the guard makes this a no-op for them.)
obj_data = entry.data.get(CONF_OBJECT, {})
if not obj_data.get("ha_device_id"):
new_data = dict(entry.data)
new_data[CONF_OBJECT] = {**obj_data, "ha_device_id": device_id}
hass.config_entries.async_update_entry(entry, data=new_data)
refreshed = hass.config_entries.async_get_entry(entry_id)
if refreshed is not None:
entry = refreshed
# Second dedup layer (discovery already hides these): never create
# a task whose name — in any language — already exists on the
# target object.
from ..helpers.integration_signatures import task_name_variants
existing_names = {
str(t.get("name", "")).lower()
for t in entry.data.get(CONF_TASKS, {}).values()
}
baselines = sel.get("baselines") or {}
for task in setup["tasks"]:
if task["task_name"] not in wanted:
continue
if existing_names & task_name_variants(task["task_name"]):
continue
sig = sig_by_key[(task["task_name"], task["direction"])]
trigger = build_setup_trigger(sig, hass, task["entity_ids"])
# #102: "last service was at reading X" — usage_delta only.
# Other directions either already have absolute semantics
# (usage_above's explicit 0) or no baseline concept at all.
baseline = baselines.get(task["task_name"])
if baseline is not None and sig.direction == "usage_delta":
trigger["trigger_baseline_value"] = float(baseline)
await async_persist_task(
hass,
entry,
{
"id": uuid4().hex,
"object_id": entry.data.get(CONF_OBJECT, {}).get("id", ""),
"name": localize_template_text(task["task_name"], lang) or task["task_name"],
"type": "replacement",
"enabled": True,
"schedule": {"kind": "manual"},
"trigger_config": trigger,
},
)
tasks_created += 1
except (ValueError, KeyError) as err:
errors.append({"device_id": device_id, "reason": str(err)})
if created_entry_id is not None:
objects_created -= 1
if hass.config_entries.async_get_entry(created_entry_id) is not None:
await hass.config_entries.async_remove(created_entry_id)
result: dict[str, Any] = {
"tasks_created": tasks_created,
"objects_created": objects_created,
"total": len(object_entries(hass)),
}
if errors:
result["errors"] = errors
connection.send_result(msg["id"], result)
@@ -420,7 +420,7 @@ async def ws_import_json(
part_id_map: dict[str, str] = {}
import_parts: dict[str, dict[str, Any]] = {}
part_stocks: dict[str, int] = {}
part_stocks: dict[str, float] = {}
parts_list = obj_entry.get("parts", [])
if isinstance(parts_list, list):
for part_entry in parts_list:
@@ -443,7 +443,7 @@ async def ws_import_json(
if old_id:
part_id_map[old_id] = new_id
stock = part_entry.get("stock")
if isinstance(stock, int) and stock >= 0:
if isinstance(stock, (int, float)) and not isinstance(stock, bool) and stock >= 0:
part_stocks[new_id] = stock
import_tasks: dict[str, dict[str, Any]] = {}
@@ -534,6 +534,22 @@ async def ws_create_from_template(
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
# Auto-number on collision: applying the same template twice (or owning
# three litter boxes) must not fail with already_configured — the second
# object becomes "Name 2", then "Name 3", … The check mirrors the config
# flow's duplicate detection (object name, case-insensitive).
existing_names = {
str(e.data.get(CONF_OBJECT, {}).get(CONF_OBJECT_NAME, "")).strip().lower()
for e in hass.config_entries.async_entries(DOMAIN)
if e.unique_id != GLOBAL_UNIQUE_ID
}
if name.strip().lower() in existing_names:
base = name[: MAX_NAME_LENGTH - 4]
for n in range(2, 100):
candidate = f"{base} {n}"
if candidate.strip().lower() not in existing_names:
name = candidate
break
object_id = uuid4().hex
new_obj: dict[str, Any] = {
"id": object_id,
@@ -41,12 +41,12 @@ _PART_FIELDS_SCHEMA = {
vol.Optional("unit"): vol.Any(str, None),
vol.Optional("cost"): vol.Any(int, float, None),
vol.Optional("reorder_threshold"): vol.Any(int, None),
vol.Optional("restock_quantity"): vol.Any(int, None),
vol.Optional("restock_quantity"): vol.Any(int, float, None),
vol.Optional("auto_buy_task"): bool,
vol.Optional("doc_id"): vol.Any(str, None),
# Initial / edited stock travels WITH the definition for dialog simplicity,
# but is stored in the per-entry Store (dynamic), not entry.data.
vol.Optional("stock"): vol.Any(int, None),
vol.Optional("stock"): vol.Any(int, float, None),
}
@@ -86,7 +86,7 @@ async def ws_create_part(
if stock is not None:
from ..parts_runtime import async_change_part_stock
await async_change_part_stock(hass, entry, part["id"], absolute=int(stock))
await async_change_part_stock(hass, entry, part["id"], absolute=float(stock))
# Reload so the part's stock sensor appears (entities are created at setup).
await hass.config_entries.async_reload(entry.entry_id)
connection.send_result(msg["id"], {"part_id": part["id"]})
@@ -137,7 +137,7 @@ async def ws_update_part(
await rd.store.async_save()
schedule_buy_task_reconcile(hass, entry)
else:
await async_change_part_stock(hass, entry, part["id"], absolute=int(stock))
await async_change_part_stock(hass, entry, part["id"], absolute=float(stock))
else:
# Threshold/opt-in edits can change the desired buy-task set.
schedule_buy_task_reconcile(hass, entry)
@@ -220,7 +220,7 @@ async def ws_delete_part(
vol.Required("part_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
# Either a relative delta (may be negative for corrections) or an
# absolute count — exactly one.
vol.Optional("delta"): vol.All(int, vol.Range(min=-MAX_PART_STOCK, max=MAX_PART_STOCK)),
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)),
}
)
@@ -20,7 +20,7 @@ from ..helpers.permissions import require_write
from ..helpers.problem_sensors import (
build_problem_task,
discover_problem_sensors,
pop_stashed_notes,
pop_stashed_config,
)
@@ -46,6 +46,9 @@ _SELECTION_SCHEMA = vol.Schema(
# Spare part to link as consumes_parts on the adopted task (discovery's
# suggested_part_id) — completing the task then consumes/restocks it.
vol.Optional("part_id"): vol.Any(vol.All(str, vol.Length(max=MAX_ID_LENGTH)), None),
# Responsible HA user for the created task (the adopt dialog offers one
# picker applied to every selection). Wins over a stashed value.
vol.Optional("responsible_user_id"): vol.Any(vol.All(str, vol.Length(max=MAX_ID_LENGTH)), None),
}
)
@@ -76,6 +79,8 @@ async def ws_adopt_problem_sensors(
selections = msg["selections"]
tasks_created = 0
objects_created = 0
# Created tasks, in order — the dialog links "configure now" to the first.
created: list[dict[str, str]] = []
# Reuse an object created earlier in THIS batch for the same device, so two
# sensors on one device don't spawn two objects.
device_to_entry: dict[str, str] = {}
@@ -115,26 +120,33 @@ async def ws_adopt_problem_sensors(
"schedule": task["schedule"],
"trigger_config": task["trigger_config"],
}
# Un-adopt → re-adopt: restore (and consume) the notes the deleted
# predecessor task had accumulated for this sensor.
restored_notes = pop_stashed_notes(hass, entity_id)
if restored_notes:
task_data["notes"] = restored_notes
# Link the suggested spare part (validated against the target
# object's parts — an unknown id is silently dropped, same as the
# task-CRUD path).
if sel.get("part_id"):
from ..const import CONF_PARTS
from ..helpers.parts import sanitize_consumes_parts
# Un-adopt → re-adopt: restore (and consume) the notes and one-time
# setup the deleted predecessor task had accumulated for this
# sensor. Restored part links are re-validated below alongside the
# dialog's suggestion (the target object/parts may have changed).
from ..const import CONF_PARTS
from ..helpers.parts import sanitize_consumes_parts
links = sanitize_consumes_parts(
[{"part_id": sel["part_id"], "quantity": 1}],
set(entry.data.get(CONF_PARTS) or {}),
)
stashed = pop_stashed_config(hass, entity_id) or {}
for field in ("notes", "responsible_user_id", "priority", "labels"):
if stashed.get(field):
task_data[field] = stashed[field]
# An explicit dialog pick wins over the stashed responsible user.
if sel.get("responsible_user_id"):
task_data["responsible_user_id"] = sel["responsible_user_id"]
# Link the suggested spare part — or, absent one, the stashed link —
# validated against the target object's parts (an unknown id is
# silently dropped, same as the task-CRUD path).
raw_links = (
[{"part_id": sel["part_id"], "quantity": 1}] if sel.get("part_id") else stashed.get("consumes_parts") or []
)
if raw_links:
links = sanitize_consumes_parts(raw_links, set(entry.data.get(CONF_PARTS) or {}))
if links:
task_data["consumes_parts"] = links
await async_persist_task(hass, entry, task_data)
tasks_created += 1
created.append({"entry_id": entry_id, "task_id": task_data["id"], "name": task_data["name"]})
except (ValueError, KeyError) as err:
errors.append({"entity_id": entity_id, "reason": str(err)})
# Roll back an object created in THIS iteration whose task failed —
@@ -150,6 +162,7 @@ async def ws_adopt_problem_sensors(
result: dict[str, Any] = {
"tasks_created": tasks_created,
"objects_created": objects_created,
"created": created,
"total": len(object_entries(hass)),
}
if errors:
@@ -95,7 +95,26 @@ def _completion_blocked(rd: Any, task_id: str) -> bool:
vol.Optional("reading_value"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-1e12, max=1e12)), None),
# 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(int, vol.Range(min=1, max=9999)), None),
vol.Optional("restock_quantity"): vol.Any(vol.All(vol.Any(int, float), vol.Coerce(float), vol.Range(min=0.01, max=9999)), 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.
vol.Optional("used_parts"): vol.Any(
vol.All(
[
vol.Schema(
{
vol.Required("part_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Optional("quantity", default=1): vol.All(
vol.Any(int, float), vol.Coerce(float), vol.Range(min=0.01, max=999)
),
}
)
],
vol.Length(max=10),
),
None,
),
}
)
@websocket_api.async_response
@@ -118,6 +137,15 @@ async def ws_complete_task(
)
return
# #99: validate the per-completion selection against the object's parts
# (unknown ids drop, quantities round) — absent stays absent so the
# automatic consumes_parts path is untouched.
used_parts = msg.get("used_parts")
if used_parts is not None:
from ..helpers.parts import sanitize_consumes_parts
used_parts = sanitize_consumes_parts(used_parts, set(_entry.data.get("parts") or {}))
await rd.coordinator.complete_maintenance(
task_id=msg["task_id"],
notes=msg.get("notes"),
@@ -128,6 +156,7 @@ async def ws_complete_task(
photo_doc_id=msg.get("photo_doc_id"),
reading_value=msg.get("reading_value"),
restock_quantity=msg.get("restock_quantity"),
used_parts=used_parts,
)
connection.send_result(msg["id"], {"success": True})
@@ -270,9 +270,7 @@ async def ws_create_task(
from ..const import CONF_PARTS
from ..helpers.parts import sanitize_consumes_parts
links = sanitize_consumes_parts(
msg["consumes_parts"], set(entry.data.get(CONF_PARTS) or {})
)
links = sanitize_consumes_parts(msg["consumes_parts"], set(entry.data.get(CONF_PARTS) or {}))
if links:
task_data["consumes_parts"] = links
if msg.get("checklist"):
@@ -540,10 +538,14 @@ async def ws_update_task(
if "trigger_config" in msg:
old_tc = tasks_data.get(task_id, {}).get("trigger_config") or {}
new_tc = msg["trigger_config"] or {}
# An edited baseline counts as fundamental: the Store baseline wins
# over the config on restore (#102 restart fix), so without clearing
# it a user-entered start value would silently never take effect.
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")
or old_tc.get("trigger_baseline_value") != new_tc.get("trigger_baseline_value")
):
rd = _get_runtime_data(hass, msg["entry_id"])
if rd and rd.store:
@@ -612,11 +614,12 @@ async def async_delete_task(
return False
old_trigger_config = new_tasks[task_id].get("trigger_config")
# Adopted problem-sensor task? Preserve its notes for a later re-adopt
# Adopted problem-sensor task? Preserve its notes + one-time setup
# (responsible user, priority, labels, part link) for a later re-adopt
# (no-op for everything else).
from ..helpers.problem_sensors import stash_task_notes_for_readopt
from ..helpers.problem_sensors import stash_task_config_for_readopt
stash_task_notes_for_readopt(hass, new_tasks[task_id])
stash_task_config_for_readopt(hass, new_tasks[task_id])
del new_tasks[task_id]
new_data[CONF_TASKS] = new_tasks
@@ -105,6 +105,7 @@ _TRIGGER_ALLOWED_KEYS: set[str] = {
# counter
"trigger_target_value",
"trigger_delta_mode",
"trigger_baseline_value",
# runtime
"trigger_runtime_hours",
"trigger_on_states",