254 lines
9.2 KiB
Python
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})
|