Updated apps
This commit is contained in:
@@ -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)
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,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",
|
||||
|
||||
Reference in New Issue
Block a user