Files
homelable/mcp/app/rack_layout.py
T
Pouzor 4c8831d5df feat(mcp): expose the rack canvas and the inventory write routes
The MCP server covered the logical canvas only. A client could create a
design whose design_type is `rack` and then had no way to put a rack, a
mount or a cable in it, and it could triage the Device Inventory but
never create, edit or delete an entry — though every route already
existed. 24 tools become 47.

Racks (mcp/app/racks.py), 13 tools: list_racks, get_rack,
list_rack_inventory, list_faceplates, create_rack, update_rack,
delete_rack, mount_device, mount_accessory, unmount_device, move_device,
set_device_faceplate, patch_cable, unpatch_cable.

`POST /api/v1/racks/save` is full-state upsert-and-prune, so every write
is load, mutate, save it all back. Two traps are encoded once, in the
helpers: the response rows carry `design_id` and the save schemas do not
(echoing one back verbatim is a 422), and the viewport has to be echoed
or the save resets the user's pan and zoom. The plate, its colour and its
ports need no special handling — /racks/save writes those through to the
inventory row itself.

Two frontend files are copied rather than imported, since the MCP server
is a separate service:

  - mcp/app/faceplates.py — the 21-plate catalog from
    frontend/src/rack/faceplates.ts, data only. `bank()` reproduces the
    port arithmetic exactly, so a plate seeded here lands its ports where
    the renderer draws them.
  - mcp/app/rack_layout.py — canPlace / findSlot / freeUnits from
    frontend/src/rack/layout.ts, to *choose* a slot. The backend still
    polices the one it is sent.

Both are guarded against drift: test_faceplates_sync.py parses
faceplates.ts and compares every plate down to each port's coordinates,
the way test_node_types_sync.py guards NODE_TYPES, and
test_layout_parity.py runs the frontend's own layout cases against the
Python port.

Devices (mcp/app/devices.py), 10 tools: create_device, update_device,
delete_device, bulk_approve_devices, bulk_hide_devices,
bulk_restore_devices, rescan_device, list_proxmox_children,
get_scan_config, update_scan_config.

Verified against a running backend: the rack flow end to end (create,
mount, patch, move, swap a plate, shrink, unmount) plus every refusal
path, and the device tools including a byte-identical scan-config
write-back.

ha-relevant: no
2026-09-04 15:10:05 +02:00

108 lines
3.6 KiB
Python

"""Rack grid geometry — a port of frontend/src/rack/layout.ts.
Only the placement half: `is_in_bounds`, `can_place`, `find_slot`, `free_units`.
The pixel maths (`uToY`, `portPosition`, …) is the renderer's business.
The backend re-checks fit, the column grid and overlap when the state is saved
(backend/app/schemas/racks.py), so this is here to *choose* a slot, not to police
one — a payload that slips through still gets a 422.
"""
from typing import Any
from .faceplates import RACK_COLUMNS
# A placement is a dict with u_start, u_height, col_start, col_span — the same
# snake_case shape the API speaks, so a mount row is its own placement.
Placement = dict[str, Any]
def _overlaps(a: Placement, b: Placement) -> bool:
u_overlap = (
a["u_start"] < b["u_start"] + b["u_height"]
and b["u_start"] < a["u_start"] + a["u_height"]
)
col_overlap = (
a["col_start"] < b["col_start"] + b["col_span"]
and b["col_start"] < a["col_start"] + a["col_span"]
)
return u_overlap and col_overlap
def clamp(value: int, low: int, high: int) -> int:
return max(low, min(high, value))
def is_in_bounds(rack: dict[str, Any], p: Placement) -> bool:
"""True when the placement fits inside the rack bounds."""
return (
p["u_start"] >= 1
and p["u_height"] >= 1
and p["u_start"] + p["u_height"] - 1 <= rack["u_height"]
and p["col_start"] >= 0
and p["col_span"] >= 1
and p["col_start"] + p["col_span"] <= RACK_COLUMNS
)
def can_place(
rack: dict[str, Any],
devices: list[dict[str, Any]],
p: Placement,
ignore_id: str | None = None,
) -> bool:
"""True when the placement fits and collides with nothing.
`ignore_id` skips the mount being moved, so a no-op move stays valid.
"""
if not is_in_bounds(rack, p):
return False
return not any(
d["rack_id"] == rack["id"] and d.get("id") != ignore_id and _overlaps(p, d)
for d in devices
)
def find_slot(
rack: dict[str, Any],
devices: list[dict[str, Any]],
desired: Placement,
ignore_id: str | None = None,
) -> Placement | None:
"""Nearest valid placement for `desired`, searching the target U first and
then walking outwards. None when the rack has no room at all."""
max_u = rack["u_height"] - desired["u_height"] + 1
if max_u < 1:
return None
col_start = clamp(desired["col_start"], 0, RACK_COLUMNS - desired["col_span"])
target_u = clamp(desired["u_start"], 1, max_u)
for offset in range(rack["u_height"]):
candidates_u = [target_u] if offset == 0 else [target_u - offset, target_u + offset]
for u in candidates_u:
if u < 1 or u > max_u:
continue
candidate = {**desired, "u_start": u, "col_start": col_start}
if can_place(rack, devices, candidate, ignore_id):
return candidate
# Same U may still have room on another column run.
for c in range(RACK_COLUMNS - desired["col_span"] + 1):
shifted = {**desired, "u_start": u, "col_start": c}
if can_place(rack, devices, shifted, ignore_id):
return shifted
return None
def free_units(rack: dict[str, Any], devices: list[dict[str, Any]]) -> int:
"""Free U count, counting a U as free only when every column is free."""
free = 0
for u in range(1, rack["u_height"] + 1):
busy = any(
d["rack_id"] == rack["id"] and u >= d["u_start"] and u < d["u_start"] + d["u_height"]
for d in devices
)
if not busy:
free += 1
return free