Files
HomeAssistantVS/custom_components/maintenance_supporter/websocket/parts.py
T
2026-07-20 22:52:35 -04:00

254 lines
9.2 KiB
Python

"""WebSocket handlers for spare parts & consumables (part/*)."""
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_PARTS, MAX_ID_LENGTH
from ..helpers.parts import (
MAX_PART_STOCK,
MAX_PARTS_PER_OBJECT,
PartValidationError,
normalize_part,
)
from ..helpers.permissions import require_write
from . import _get_runtime_data, _load_object_entry
def _parts_of(entry: Any) -> dict[str, dict[str, Any]]:
parts = entry.data.get(CONF_PARTS)
return dict(parts) if isinstance(parts, dict) else {}
def _persist_parts(hass: HomeAssistant, entry: Any, parts: dict[str, dict[str, Any]]) -> None:
new_data = dict(entry.data)
new_data[CONF_PARTS] = parts
hass.config_entries.async_update_entry(entry, data=new_data)
_PART_FIELDS_SCHEMA = {
vol.Required("name"): str,
vol.Optional("mpn"): vol.Any(str, None),
vol.Optional("gtin"): vol.Any(str, None),
vol.Optional("vendor"): vol.Any(str, None),
vol.Optional("storage_location"): vol.Any(str, None),
vol.Optional("product_url"): vol.Any(str, None),
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),
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, float, None),
}
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/part/create",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
**_PART_FIELDS_SCHEMA,
}
)
@require_write
@websocket_api.async_response
async def ws_create_part(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Add a part to an object."""
entry = _load_object_entry(hass, connection, msg)
if entry is None:
return
parts = _parts_of(entry)
if len(parts) >= MAX_PARTS_PER_OBJECT:
connection.send_error(msg["id"], "limit_reached", f"At most {MAX_PARTS_PER_OBJECT} parts per object")
return
try:
# msg["id"] is the WS envelope's message id — NEVER the part id; force
# a fresh uuid (normalize_part generates one when id is falsy).
part = normalize_part({**msg, "id": None})
except PartValidationError as err:
connection.send_error(msg["id"], "invalid_input", str(err))
return
parts[part["id"]] = part
_persist_parts(hass, entry, parts)
stock = msg.get("stock")
if stock is not None:
from ..parts_runtime import async_change_part_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"]})
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/part/update",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("part_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
**_PART_FIELDS_SCHEMA,
}
)
@require_write
@websocket_api.async_response
async def ws_update_part(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Edit a part definition (and optionally its stock)."""
entry = _load_object_entry(hass, connection, msg)
if entry is None:
return
parts = _parts_of(entry)
existing = parts.get(msg["part_id"])
if existing is None:
connection.send_error(msg["id"], "not_found", "Part not found")
return
payload = {**existing, **{k: v for k, v in msg.items() if k in _PART_FIELD_KEYS}}
payload["id"] = msg["part_id"]
try:
part = normalize_part(payload)
except PartValidationError as err:
connection.send_error(msg["id"], "invalid_input", str(err))
return
parts[part["id"]] = part
_persist_parts(hass, entry, parts)
from ..parts_runtime import async_change_part_stock, schedule_buy_task_reconcile
if "stock" in msg:
stock = msg.get("stock")
if stock is None:
rd = _get_runtime_data(hass, entry.entry_id)
if rd and rd.store:
rd.store.set_part_stock(part["id"], None)
await rd.store.async_save()
schedule_buy_task_reconcile(hass, entry)
else:
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)
connection.send_result(msg["id"], {"success": True})
_PART_FIELD_KEYS = {k.schema for k in _PART_FIELDS_SCHEMA if str(k.schema) != "stock"}
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/part/delete",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("part_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
}
)
@require_write
@websocket_api.async_response
async def ws_delete_part(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Remove a part: definition, stock state, task links, open buy task."""
entry = _load_object_entry(hass, connection, msg)
if entry is None:
return
parts = _parts_of(entry)
if msg["part_id"] not in parts:
connection.send_error(msg["id"], "not_found", "Part not found")
return
del parts[msg["part_id"]]
# Prune task-side consumption links pointing at the deleted part.
from ..const import CONF_TASK_CONSUMES_PARTS, CONF_TASKS
new_data = dict(entry.data)
new_data[CONF_PARTS] = parts
tasks = dict(new_data.get(CONF_TASKS, {}))
for tid, td in list(tasks.items()):
links = td.get(CONF_TASK_CONSUMES_PARTS)
if isinstance(links, list) and any(isinstance(x, dict) and x.get("part_id") == msg["part_id"] for x in links):
td = dict(td)
td[CONF_TASK_CONSUMES_PARTS] = [
x for x in links if not (isinstance(x, dict) and x.get("part_id") == msg["part_id"])
]
if not td[CONF_TASK_CONSUMES_PARTS]:
td.pop(CONF_TASK_CONSUMES_PARTS, None)
tasks[tid] = td
new_data[CONF_TASKS] = tasks
hass.config_entries.async_update_entry(entry, data=new_data)
rd = _get_runtime_data(hass, entry.entry_id)
if rd and rd.store:
rd.store.remove_part(msg["part_id"])
await rd.store.async_save()
# Remove the part's stock sensor from the entity registry (same
# contained-segment match the task delete uses; part ids are uuid4).
from homeassistant.helpers import entity_registry as er
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"_part_{msg['part_id']}" in ent_entry.unique_id:
ent_reg.async_remove(ent_entry.entity_id)
# The reconcile removes an open buy task for the now-gone part (it reloads
# when it changes anything); reload here regardless so the sensor vanishes.
from ..parts_runtime import schedule_buy_task_reconcile
await hass.config_entries.async_reload(entry.entry_id)
schedule_buy_task_reconcile(hass, entry)
connection.send_result(msg["id"], {"success": True})
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/part/restock",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
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(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)),
}
)
@require_write
@websocket_api.async_response
async def ws_restock_part(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Adjust a part's on-hand stock (inventory correction / manual restock)."""
entry = _load_object_entry(hass, connection, msg)
if entry is None:
return
if ("delta" in msg) == ("absolute" in msg):
connection.send_error(msg["id"], "invalid_input", "Provide exactly one of delta / absolute")
return
from ..parts_runtime import async_change_part_stock
new = await async_change_part_stock(
hass,
entry,
msg["part_id"],
delta=msg.get("delta"),
absolute=msg.get("absolute"),
)
if new is None:
connection.send_error(msg["id"], "not_found", "Part not found")
return
connection.send_result(msg["id"], {"stock": new})