Big rescope driven by m's product-vision clarification: mCables is a cable-management framework with a solver as its core value prop, not a manual draw-and-click editor. m declares devices + required connections between them; the solver emits the cable plan + bundle recommendations, optimising for maximum bundling. Schema additions (migrations 002 + 003): - device_types (catalog) — built-ins (project_id NULL) + project-custom (project_id non-null). 11 built-in types seeded with default port profiles (NAS, PC, Mac, TV, Soundbar, Switch, fritz, ChromeCast, SteamLink, IOx-3/6/8, Notebook). - device_type_ports (profile rows: cable_type × count × edge). - devices.type_id (nullable). Picking a type seeds ports once; instance-owned thereafter (no retroactive re-seed). - connection_requirements (per-project, from/to device + preferred type + must_connect flag, with order-normalised pair_lo/pair_hi for duplicate prevention). - cables.auto (slice 5.5 migration) — distinguishes solver-owned cables from user-drawn ones. API additions: - GET /api/device-types (built-ins only, read-only) and GET /api/projects/:pid/device-types (built-ins + project-custom merged) - POST/PATCH/DELETE under /api/projects/:pid/device-types (project-custom only; built-ins are 403) - /api/projects/:pid/connection-requirements full CRUD - POST /api/projects/:pid/solve with ?preview=1 — pure-function solver (greedy port allocation, endpoint-pair bundling for v0); returns add[], remove[], bundles_added[], unsatisfied[], warnings[] Solver algorithm (§5b): - Read project devices + ports + connection_requirements + manual cables - Assign each requirement a (port_a, port_b) using the preferred cable type (or auto-pick if exactly one type matches both ends) - Bundle by endpoint-pair (v3 rule, applied to auto cables only) - Surface unsatisfied requirements per class (no compat type / ambiguous type / no free port) — does NOT auto-add ports; UI quick-fix instead - ?preview=1 returns the diff without writing; default applies in a tx UI additions: - Device-create modal: type dropdown (built-ins grouped by kind, then project-custom, then "Custom (no type)" for the v3 freeform fallback) - Left-sidebar Requirements section with + Requirement button - Header Solve button (S keybinding) → preview modal → Apply - Inspector for selected device: type, ports grid, unmet requirements with red badges + quick-fix actions - Inspector for selected auto cable: driving requirement, parent bundle, Promote-to-manual button Slice reshape (§8): - Slices 1, 2 shipped. v4 inserts: 4 = catalog + type-aware device create, 4.5 = catalog management, 5 = requirements CRUD + UI, 6 = solver MVP + Solve button. Old "manual port + manual cable draw" slides to slice 7 as a tweak path on solver output. Export becomes slice 8. Six new open questions (§9) for m to gate before slice 4: 1. Path source (auto-route through frame edges / user cable-trays / Steiner-tree)? 2. Live-solve vs. button-only? 3. UX when solver has no compatible port pair? 4. Setup templates in v4 or post-MVP? 5. Catalog as code seed or JSON file? 6. Auto-promote vs. explicit Promote-to-manual on solver cable edits? CLAUDE.md updated to reflect the solver-core framing, hybrid catalog, connection-requirements model, and auto/manual cable distinction. Trailer changes to "DESIGN v4 READY FOR REVIEW".
63 KiB
mCables — Design v4
Cable-management framework + solver for m's setup. Inventor shift 1 design, revised through v2 (rescope to multi-project framework), v3 (global cable_types + guardrails), and now v4 — solver-as-core.
Sources: the live Cable-Management.excalidraw on mxdrw.msbls.de (used as
the visual-grammar reference, not a bootstrap import target),
mai-memory (mcables, m), and the live mDock services for deploy
conventions (§10). v4 driven by m's product-vision clarification:
"we provide a cable manager — I say what devices we have, the app tells me how to bundle cables and how the most efficient connection looks like"
mCables shifts from a manual draw-and-click editor to a solver that takes a list of devices + the connections m needs and emits the cable plan + bundle recommendations. The manual editor stays (it's the only way to inspect + tweak the plan) but is no longer the primary surface.
What changed in v4 (new mental model on top of v3 mechanics)
- Hybrid device-type catalog (§2.1, §3.1). A built-in
device_typestable seeds common devices (NAS, PC, Mac, TV, Soundbar, Switch, fritz, ChromeCast, SteamLink, IOx-3/6/8, Notebook, …) with default port profiles (device_type_portsrows: cable_type + count + label). Adding a device → pick a type → ports auto-seed. m can override per instance (this PC has 3 USB, not 2). Catalog is extendable per project.connection_requirementstable (§2.2). m declares "NAS must connect to Switch via RJ45" once. Many per device. The solver consumes these.POST /api/projects/:pid/solveendpoint (§3.2). Reads devices + their ports + connection_requirements + frame positions, emits a diff ofcables+bundles. Two modes:?preview=1returns the diff without applying; default applies.- Solver objective: maximum bundling (§5). Prefer routes that consolidate cables into trunks even at higher total length. Visually cleaner setups, easier mental model. v0 uses the v3 same-endpoints bundle rule; path-based bundling is slice 6+.
- UI: device-type dropdown on device-create, Connection Requirements left panel, Solve button next to Export. Inspector shows type + ports + unmet requirements (selected device) or the driving requirement + bundle (selected cable).
- Slices reshape (§8). Catalog seeding lands early (slice 1.5); the solver MVP and connection-requirements UI move ahead of the bundle-rendering polish.
What carried over from v3 (unchanged in v4)
- mCables is a framework: top-level
projects, each backed by one.excalidrawdrawing.UNIQUE(projects.name).cable_typesis global. Migration 001 seeds Power/USB/HDMI/DP/RJ45.devicesUNIQUE(project_id, name);frame_idnullable; FrameRef tri-state on PATCH.- IO diamonds = wall-outlet terminators (type=Power by convention).
projects.drawing_nameauto-defaults to<name>.excalidraw.DELETE /api/projects/:pid?confirm=<name>guardrail.- No cable inventory metadata; visual + connectivity structure only.
- DB at
./data/mcables.db(gitignored). Bind0.0.0.0:7777LAN, no auth.- Deploy on mDock under
/home/m/stacks/mcables/, raw docker-compose.What's superseded in v4
- The "manual draw-a-cable port-to-port" flow from v3 §7 is kept as a tweak path on the solver output, but is no longer the primary device- connecting flow. The solve button is the headline action.
- The v3 §8 slice order changes — catalog + types-driven devices + solver come earlier; the manual-draw-cable slice slides later. See new §8.
0. The seed drawing — visual grammar reference
Cable-Management.excalidraw on mxdrw.msbls.de is not ingested at
runtime. It is the visual-grammar reference we lock the export onto so that
when m rebuilds LOFT and OFFICE inside mCables, the exported .excalidraw
looks like the seed.
Concrete numbers from the live file (180 elements):
| Kind | Count | Excalidraw shape | What it represents |
|---|---|---|---|
| Frames | 2 | frame (name) |
Sub-areas inside a project (desk, rack, …) |
| Devices | 27 | rectangle with bound text |
Hardware items |
| Ports | 74 | ellipse ~12×9 |
Connectors on a device edge, colour = cable type |
| Cables | 31 | arrow |
Typed connections between ports/devices/outlets |
| IO markers | 6 | diamond text=IO |
Wall outlet / power-entry terminators (type=Power) |
| Legend | 5 | text |
Colour key in the top-left of the frame |
| Lines | 5 | line |
Decorative (separator under the legend). Ignored. |
Legend → cable type → colour, picked up directly from the seed:
| Type | Colour | Hex |
|---|---|---|
| Power | red | #e03131 |
| USB | green | #2f9e44 |
| HDMI | blue | #1971c2 |
| DP | purple | #9c36b5 |
| RJ45 | yellow | #ffd500 |
Three observations about the seed's visual grammar — these constrain the exporter (§4):
- Ports sit on a device edge as small ellipses (~12×9), coloured by
cable type. They are not children of the device in the Excalidraw sense
(no
containerId/boundElementslink) — purely positional. When we export from mCables we mimic that: port ellipse at(device.x + port.x_offset, device.y + port.y_offset), stroke colour = type colour. - Cable arrows bind to elements. In the seed: 44 endpoints to ellipses
(ports), 12 to whole rectangles (device-level, no specific port), 3 to
diamonds (wall outlets). Our exporter sets
startBinding.elementId/endBinding.elementIdto whichever Excalidraw element ID we wrote for the port / device / IO marker. - IO diamonds = wall outlets. They are terminals: a cable goes from a device-port → an IO marker, meaning "this cable plugs into a wall socket outside the diagram". They are always type=Power in m's setup but the schema doesn't enforce that (a future "network jack in the wall" wouldn't fit, and we can lift the constraint then).
1. Frontend stack — vanilla JS + SVG
Locked: vanilla ES modules (TS-typed via JSDoc, no build step) + SVG
diagram surface, served from a single Go binary via embed.FS.
Why this fits m: matches the no-build-step preference; same single-binary
aesthetic as m, mai, youpcms, mExDraw. Type-checking is opt-in via
make typecheck (tsc --noEmit), not gating runtime. SVG is one DOM node
per port/device/cable → trivial hit-testing, CSS-driven colouring by
data-type=hdmi, drag via pointer events + getScreenCTM().
Escape hatch only if state for half-drawn cables + multi-select gets painful: switch to Preact-via-CDN-ESM (still no build step). Not v0.
2. SQLite schema
./data/mcables.db (project-local, gitignored). WAL mode, FKs on.
Driver: modernc.org/sqlite (cgo-free — clean cross-compile, simple
Dockerfile).
-- 001_init.sql
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
-- A project IS a drawing. LOFT and OFFICE are separate projects.
-- One project ↔ one .excalidraw file in mExDraw.
CREATE TABLE projects (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE, -- "LOFT", "OFFICE"
drawing_name TEXT NOT NULL, -- mExDraw drawing name, e.g. "LOFT.excalidraw"
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Cable types: GLOBAL legend, one set shared across all projects.
-- Migration 001 seeds the 5 defaults (Power/USB/HDMI/DP/RJ45) once.
-- Renaming or recolouring a type from anywhere in the UI propagates to
-- every project's legend and to every cable already typed as it.
CREATE TABLE cable_types (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE, -- "Power", "USB", "HDMI", "DP", "RJ45"
color TEXT NOT NULL, -- "#e03131"
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- v4 — device-type catalog. Seeded built-in types live globally (so
-- multiple projects share the "NAS" definition without duplication).
-- Per-project custom types are also allowed (project_id non-null for those).
-- Renaming a built-in type doesn't propagate retroactively to existing
-- devices that already had their ports seeded — they own their port set
-- from the moment they were created.
CREATE TABLE device_types (
id INTEGER PRIMARY KEY,
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
-- NULL = built-in (shared), non-null = project-custom
name TEXT NOT NULL, -- "NAS", "PC", "TV", "Switch", "IOx-8", "Custom-Foo"
kind TEXT NOT NULL DEFAULT 'generic',
-- coarse category for UI grouping: 'storage', 'compute',
-- 'display', 'audio', 'network', 'hub', 'accessory',
-- 'generic'
icon TEXT, -- emoji or short symbol (🖥, 📺, 🔊, 📡) — UI hint
description TEXT NOT NULL DEFAULT '',
built_in INTEGER NOT NULL DEFAULT 0, -- 1 for migration-seeded rows, 0 for user-created
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, name) -- two projects can both have a custom "Foo";
-- built-ins (project_id NULL) get UNIQUE on name globally
);
CREATE INDEX device_types_project_idx ON device_types(project_id);
-- v4 — port profile per device type. "NAS has 1 Power + 1 RJ45" is two
-- rows; "PC has 1 Power + 1 RJ45 + 1 HDMI + 2 USB" is four rows.
-- When a device is created with type_id=X, the seeder inserts `count`
-- rows into the `ports` table for each device_type_ports entry,
-- numbering label as "<label_prefix> N" if count > 1.
CREATE TABLE device_type_ports (
id INTEGER PRIMARY KEY,
device_type_id INTEGER NOT NULL REFERENCES device_types(id) ON DELETE CASCADE,
cable_type_id INTEGER NOT NULL REFERENCES cable_types(id) ON DELETE RESTRICT,
label_prefix TEXT NOT NULL DEFAULT '', -- "HDMI", "USB", "Power" — UI label root
count INTEGER NOT NULL DEFAULT 1 CHECK (count >= 1),
-- Position hint: the seeder lays ports along the device edge using
-- these biases (0..1 along the edge fraction). NULL = even spread.
edge TEXT NOT NULL DEFAULT 'bottom' CHECK (edge IN ('top','bottom','left','right')),
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX device_type_ports_type_idx ON device_type_ports(device_type_id);
-- A frame is a named container *inside* a project: 'desk', 'rack', 'media'.
CREATE TABLE frames (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
x REAL NOT NULL DEFAULT 0,
y REAL NOT NULL DEFAULT 0,
width REAL NOT NULL DEFAULT 1200,
height REAL NOT NULL DEFAULT 800,
excalidraw_id TEXT, -- stable across exports
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, name),
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX frames_project_idx ON frames(project_id);
-- Devices live in a frame (and transitively in a project).
-- Stored project_id is denormalised for cheap project-scoped queries; FK
-- to frame_id is the structural truth. Both are kept consistent in code.
--
-- v4 — type_id (nullable) lets a device inherit its port profile from
-- a `device_types` row. Once ports are seeded the device "owns" them;
-- changing/clearing type_id later does not retroactively re-seed (m's
-- per-instance overrides survive). Custom freeform devices (no template)
-- keep type_id NULL — that's the v3 "just a rectangle" device.
CREATE TABLE devices (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
frame_id INTEGER REFERENCES frames(id) ON DELETE SET NULL,
type_id INTEGER REFERENCES device_types(id) ON DELETE SET NULL,
-- v4: nullable; SET NULL on type delete so we don't
-- cascade-delete a device the user still wants
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#1e1e1e',
x REAL NOT NULL,
y REAL NOT NULL,
width REAL NOT NULL,
height REAL NOT NULL,
excalidraw_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, name), -- no two devices in one project share a name
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX devices_project_idx ON devices(project_id);
CREATE INDEX devices_frame_idx ON devices(frame_id);
CREATE INDEX devices_type_idx ON devices(type_id);
-- Ports belong to a device. x_offset/y_offset are relative to the device's
-- top-left so ports follow when the device moves. project_id denormalised.
CREATE TABLE ports (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
type_id INTEGER NOT NULL REFERENCES cable_types(id) ON DELETE RESTRICT,
label TEXT, -- optional ("HDMI 1", "USB-C rear")
x_offset REAL NOT NULL,
y_offset REAL NOT NULL,
excalidraw_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX ports_project_idx ON ports(project_id);
CREATE INDEX ports_device_idx ON ports(device_id);
CREATE INDEX ports_type_idx ON ports(type_id);
-- IO markers = wall outlets / power-entry terminators.
-- One end of a Power cable. They are NOT bridges and they do NOT pair.
CREATE TABLE io_markers (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
frame_id INTEGER REFERENCES frames(id) ON DELETE SET NULL,
label TEXT NOT NULL DEFAULT 'IO', -- "Wall A", "UPS rear", …
x REAL NOT NULL,
y REAL NOT NULL,
excalidraw_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX io_markers_project_idx ON io_markers(project_id);
CREATE INDEX io_markers_frame_idx ON io_markers(frame_id);
-- A cable. Each endpoint is exactly one of (port, device, io-marker).
-- All foreign-key targets must be in the same project_id as the cable —
-- enforced in code (the CHECK below only enforces the one-non-null rule).
CREATE TABLE cables (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
type_id INTEGER NOT NULL REFERENCES cable_types(id) ON DELETE RESTRICT,
label TEXT,
from_port_id INTEGER REFERENCES ports(id) ON DELETE SET NULL,
from_device_id INTEGER REFERENCES devices(id) ON DELETE SET NULL,
from_io_id INTEGER REFERENCES io_markers(id) ON DELETE SET NULL,
to_port_id INTEGER REFERENCES ports(id) ON DELETE SET NULL,
to_device_id INTEGER REFERENCES devices(id) ON DELETE SET NULL,
to_io_id INTEGER REFERENCES io_markers(id) ON DELETE SET NULL,
excalidraw_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
CHECK (
(from_port_id IS NOT NULL) + (from_device_id IS NOT NULL) + (from_io_id IS NOT NULL) = 1
),
CHECK (
(to_port_id IS NOT NULL) + (to_device_id IS NOT NULL) + (to_io_id IS NOT NULL) = 1
),
UNIQUE (project_id, excalidraw_id)
);
CREATE INDEX cables_project_idx ON cables(project_id);
CREATE INDEX cables_from_port_idx ON cables(from_port_id);
CREATE INDEX cables_to_port_idx ON cables(to_port_id);
CREATE INDEX cables_from_device_idx ON cables(from_device_id);
CREATE INDEX cables_to_device_idx ON cables(to_device_id);
CREATE INDEX cables_type_idx ON cables(type_id);
-- Bundles: named groups of cables that physically run together, within
-- a single project (a bundle does not span LOFT ↔ OFFICE).
CREATE TABLE bundles (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
auto INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (project_id, name)
);
CREATE INDEX bundles_project_idx ON bundles(project_id);
CREATE TABLE bundle_cables (
bundle_id INTEGER NOT NULL REFERENCES bundles(id) ON DELETE CASCADE,
cable_id INTEGER NOT NULL REFERENCES cables(id) ON DELETE CASCADE,
PRIMARY KEY (bundle_id, cable_id)
);
CREATE INDEX bundle_cables_cable_idx ON bundle_cables(cable_id);
-- v4 — connection_requirements: the input m gives the solver.
-- "NAS must connect to Switch via RJ45" is one row. Many per device.
--
-- preferred_cable_type_id is the cable type m intends — the solver
-- needs it to match port colours. NULL means "solver picks" (the solver
-- will pick the unique cable_type that is compatible with both ends'
-- available port types; if ambiguous it surfaces an error for m).
--
-- must_connect = 1 (default) means the solver MUST satisfy this; an
-- unsatisfiable must_connect surfaces as a hard error in the solve
-- result. must_connect = 0 = "nice to have, drop if you run out of
-- ports". Used for templates that over-spec.
--
-- The (from_device_id, to_device_id) pair is normalised on insert so
-- (A,B) and (B,A) are the same requirement — UNIQUE on the unordered
-- pair + cable type prevents duplicates.
CREATE TABLE connection_requirements (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
from_device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
to_device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
preferred_cable_type_id INTEGER REFERENCES cable_types(id) ON DELETE SET NULL,
must_connect INTEGER NOT NULL DEFAULT 1 CHECK (must_connect IN (0, 1)),
notes TEXT NOT NULL DEFAULT '',
-- Order-normalised pair: lo = MIN(from, to), hi = MAX(from, to). Set
-- in code on insert; the UNIQUE then prevents (A,B,Power) AND
-- (B,A,Power) from coexisting. Stored alongside the m-facing
-- from/to so the UI doesn't have to denormalise.
pair_lo INTEGER NOT NULL,
pair_hi INTEGER NOT NULL,
CHECK (from_device_id != to_device_id),
UNIQUE (project_id, pair_lo, pair_hi, preferred_cable_type_id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX conn_reqs_project_idx ON connection_requirements(project_id);
CREATE INDEX conn_reqs_pair_idx ON connection_requirements(project_id, pair_lo, pair_hi);
CREATE INDEX conn_reqs_from_idx ON connection_requirements(from_device_id);
CREATE INDEX conn_reqs_to_idx ON connection_requirements(to_device_id);
2.1 Migration sequence
- 001_init.sql (v3) — projects, frames, devices (no type_id), ports, cable_types (5 seeded), io_markers, cables, bundles, bundle_cables.
- 002_device_catalog.sql (v4 NEW) —
device_types+device_type_ports. Seeds the built-in catalog (§2.2). Addsdevices.type_id(ALTER TABLE devices ADD COLUMN type_id INTEGER REFERENCES device_types(id) ON DELETE SET NULL) and the matching index. - 003_connection_requirements.sql (v4 NEW) —
connection_requirements.
Slice 1 already shipped 001. Slices 1.5 (catalog) and 1.6 (requirements) land 002 and 003.
2.2 Built-in catalog seed (002 INSERTs)
The 11 built-in types m's setup uses today, with their default port
profiles. Stored as (project_id NULL, built_in 1):
device_types.name |
kind |
Default ports (cable_type × count) |
|---|---|---|
| NAS | storage | Power × 1; RJ45 × 1 |
| PC | compute | Power × 1; RJ45 × 1; HDMI × 1; USB × 2 |
| Mac | compute | Power × 1; HDMI × 1; USB × 2 |
| Notebook | compute | Power × 1; USB × 2 |
| TV | display | Power × 1; HDMI × 2 |
| Soundbar | audio | Power × 1; HDMI × 1 |
| Switch | network | Power × 1; RJ45 × 5 |
| fritz | network | Power × 1; RJ45 × 4 |
| ChromeCast | display | Power × 1; HDMI × 1 |
| SteamLink | compute | Power × 1; HDMI × 1; USB × 2 |
| IOx-3 | hub | Power × 1; (3× port slots — concrete cable type per slot is set at instantiation; defaults to USB × 3 for v0) |
| IOx-6 | hub | Power × 1; USB × 6 |
| IOx-8 | hub | Power × 1; USB × 8 |
"Hub" devices like IOx-* have ambiguous port profiles (the seed drawing shows them in red because most carry Power, but they also hub USB). v0 seeds them as USB hubs; m overrides per-instance. The catalog is editable in the UI (slice 4.5 — "Manage device types") so m can refine the IOx-3 profile once and not re-override every instance.
m can also add project-custom types at any time (UI: "+ New device
type" inside the device-create modal) with project_id = current.
2.3 Why ports are still instance-owned
When m picks a type to create a device, the seeder calls count × INSERT
into ports. From that moment on, ports are instance-level rows owned by
that device. Deleting a port from this PC doesn't touch other PCs;
changing a type's port profile (in slice 4.5) doesn't retroactively
re-seed already-created devices — it only affects subsequent device
creations.
Trade-off acknowledged: m may want a "re-seed from type" action later (slice 5+) to wipe + reset a device's ports. Out of v0 scope; not blocked by the schema.
FK shape — why project_id on every project-scoped row, not just transitively:
The structural truth is cable → port → device → frame → project. But
project-scoped queries ("give me all cables in OFFICE") would otherwise need
three joins. Denormalising project_id onto every project-scoped row is a
small, load-bearing pragma: cables WHERE project_id=? is a one-column
index hit. The cost: code must keep project_id consistent with frame_id
/ device_id on insert+update. That's enforced at the Go layer
(internal/db/store.go setter functions), not by SQL — CHECK constraints
in SQLite can't reference another table.
cable_types is the one global table — it has no project_id.
Cables reference it cross-project. Renaming or recolouring a type updates
the legend everywhere immediately and re-renders every cable of that type
on the next paint.
ON DELETE CASCADE from projects cleanly wipes a project's whole subgraph
in one statement, which is what we want when m says "delete OFFICE". The
cascade does not touch cable_types (no FK to projects).
3. Go HTTP API
Single binary cmd/mcables, net/http, no router framework. Listens on
0.0.0.0:7777 by default (overridable via MCABLES_ADDR). Static frontend
from embed.FS at /, JSON API under /api/.
GET / → index.html (embedded)
GET /assets/* → JS/CSS/SVG (embedded)
GET /api/healthz → 200 ok
# Projects — top-level
GET /api/projects → [Project, …]
POST /api/projects ← {name, drawing_name?, description?}
If drawing_name is omitted, server defaults to
"<name>.excalidraw". No cable-type seeding —
cable_types is global (see /api/cable-types).
GET /api/projects/:pid → full snapshot
{project, frames, devices, ports, cables,
io_markers, bundles}
Plus the global cable_types (clients can also
fetch them via /api/cable-types). Editor's
one-shot loader.
PATCH /api/projects/:pid ← partial {name, drawing_name, description}
DELETE /api/projects/:pid?confirm=<name> Confirmation guardrail — the query param must
equal the project's current name. 400 if missing
or mismatched. Cascades through all child rows
(frames, devices, ports, cables, io_markers,
bundles, bundle_cables). Does NOT touch
cable_types.
# Cable types — GLOBAL, NOT under a project
GET /api/cable-types → [CableType, …]
POST /api/cable-types ← {name, color} # name must be unique globally
PATCH /api/cable-types/:id ← {name?, color?} # affects every project's legend + every cable using this type
DELETE /api/cable-types/:id # blocked if any cable still references it (ON DELETE RESTRICT)
# Inside a project — everything below scoped under :pid
GET /api/projects/:pid/frames
POST /api/projects/:pid/frames ← {name, x, y, width, height}
PATCH /api/projects/:pid/frames/:id
DELETE /api/projects/:pid/frames/:id
GET /api/projects/:pid/devices
POST /api/projects/:pid/devices ← {name, type_id?, frame_id?, x, y, width, height, color?}
v4: type_id (optional) seeds ports from the catalog;
without it, a freeform device (no ports) is created.
PATCH /api/projects/:pid/devices/:id (e.g. {x, y} on drag). type_id can be set or cleared;
clearing does NOT delete existing ports (instance-owned).
DELETE /api/projects/:pid/devices/:id
GET /api/projects/:pid/devices/:id/ports
POST /api/projects/:pid/devices/:id/ports ← {type_id, x_offset, y_offset, label?}
PATCH /api/projects/:pid/ports/:id
DELETE /api/projects/:pid/ports/:id
GET /api/projects/:pid/cables
POST /api/projects/:pid/cables ← {type_id, from_{port|device|io}_id,
to_{port|device|io}_id, label?}
PATCH /api/projects/:pid/cables/:id
DELETE /api/projects/:pid/cables/:id
GET /api/projects/:pid/io-markers
POST /api/projects/:pid/io-markers ← {frame_id?, label, x, y}
PATCH /api/projects/:pid/io-markers/:id
DELETE /api/projects/:pid/io-markers/:id
GET /api/projects/:pid/bundles → [{Bundle, cable_ids: [int]}, …]
POST /api/projects/:pid/bundles ← {name, cable_ids: [int]}
GET /api/projects/:pid/bundles/suggestions → [{name, cable_ids}, …] (see §5)
PATCH /api/projects/:pid/bundles/:id
DELETE /api/projects/:pid/bundles/:id
# v4 — Device-type catalog (mostly global, project-scoped writes for custom rows)
GET /api/device-types → built-in catalog (project_id NULL) — read-only listing
GET /api/projects/:pid/device-types → built-ins + this project's custom types, merged
POST /api/projects/:pid/device-types ← {name, kind?, icon?, description?, ports: [{cable_type_id, count, label_prefix?, edge?}]}
Creates a project-custom row (built_in=0); inserts
device_type_ports rows in the same transaction.
PATCH /api/projects/:pid/device-types/:id ← partial. Only project-custom types are PATCHable;
mutating a built-in row → 403 (UI hides edit affordance).
Editing ports replaces the device_type_ports rows;
existing devices' ports are NOT retroactively reseeded.
DELETE /api/projects/:pid/device-types/:id Only project-custom; built-ins → 403.
ON DELETE SET NULL on devices.type_id so devices
keep their already-seeded ports.
# v4 — Connection requirements (the solver's input)
GET /api/projects/:pid/connection-requirements → [ConnectionRequirement, …]
POST /api/projects/:pid/connection-requirements ← {from_device_id, to_device_id,
preferred_cable_type_id?, must_connect?, notes?}
Server normalises (from, to) into (pair_lo, pair_hi)
before insert; duplicate (project, pair_lo, pair_hi,
preferred_cable_type_id) → 409 conflict.
PATCH /api/projects/:pid/connection-requirements/:id
DELETE /api/projects/:pid/connection-requirements/:id
# v4 — Solver
POST /api/projects/:pid/solve ← {} (or {?preview=1} to compute without applying)
→ {
cables_added: [Cable, …],
cables_kept: [int, …], # ids preserved by the diff
cables_removed: [int, …], # ids deleted (auto cables only)
bundles_added: [{Bundle, cable_ids: [int]}, …],
bundles_removed: [int, …],
unsatisfied: [{requirement_id, reason}, …],
warnings: [string, …],
}
Default applies in a single transaction. ?preview=1
returns the same shape without writing. User-created
cables (auto=0 in the cables table; see §5.1) are
never touched — the solver only adds/removes its own.
# Sync — export only in MVP
POST /api/projects/:pid/sync/export → writes the project's drawing to mExDraw
(overwrites previous version; mExDraw keeps
git-version-history sidecar)
3.1 v4 wire-shape additions
ConnectionRequirement(response):{id, project_id, from_device_id, to_device_id, preferred_cable_type_id|null, must_connect: bool, notes, created_at, updated_at}.DeviceType(response):{id, project_id|null, name, kind, icon|null, description, built_in: bool, ports: [{cable_type_id, count, label_prefix, edge, sort_order}]}.cablesgets anauto: boolfield on the row (slice 5.5 migration adds the column with default 0; the solver sets 1 on its own creations). The v3 cable rows m hand-drew keepauto=0.POST /api/.../cablescontinues to defaultauto=0; only the solver writesauto=1.
No POST /api/sync/import in MVP. Import is post-MVP and only ever serves
a one-shot migration use case (e.g. seeding LOFT from the legacy
Cable-Management drawing if m later changes his mind).
All write endpoints return the updated row. Errors are
{error: "string", details?: any}. No auth.
mExDraw HTTP credentials live in MEXDRAW_BASE_URL (e.g.
https://mxdrw.msbls.de) + MEXDRAW_TOKEN (bearer). The exporter calls
PUT $MEXDRAW_BASE_URL/api/drawings/<drawing_name>.excalidraw with the
generated scene JSON.
4. Export — DB → Excalidraw (visual-grammar conformance)
mCables generates a .excalidraw scene from a project's rows. The seed
drawing's grammar is the contract.
4.1 Element mapping
| DB row | Excalidraw element | Notes |
|---|---|---|
projects.drawing_name |
drawing filename in mExDraw | one drawing per project |
frames |
type=frame, name=frames.name |
x/y/width/height straight across |
devices |
type=rectangle + bound text with name |
strokeColor=color, frameId=frames.excalidraw_id |
ports |
type=ellipse, ~12×9 |
strokeColor=type.color, absolute pos = (device.x + port.x_offset, device.y + port.y_offset), no containerId binding (matches seed) |
io_markers |
type=diamond with bound text=label |
small (~30×30), strokeColor = the Power cable type's colour |
cables |
type=arrow |
strokeColor=type.color, startBinding.elementId = port/device/io excalidraw_id, same for end |
cable_types legend (global) |
one type=text row per cable_types row, top-left of the project's first frame |
strokeColor=color, text=name. Pulled from the global table, regenerated each export. |
bundles |
(rendering open question — see §5) | post-MVP: render as a thick path; v0: ignored on export |
4.2 Element IDs are stable across exports
Every mCables row carries excalidraw_id (TEXT, generated on first export
via crypto/rand → 21-char Excalidraw-style ID). On re-export the same row
reuses the same ID. This means:
- m's
.excalidrawcollaborator-cursors, element-comments, and undo history survive a re-export. - If m manually edits a port colour in Excalidraw (someday, once import exists), we can match it back to the right DB row by ID.
4.3 What is not in the export
- The legend's decorative separator lines (the 5
type=lineelements in the seed) — purely visual, m said they're not load-bearing. - Big "enclosure" rectangles like the seed's
tAs8zMDIdesk-surface. In v0 those are imported as plain devices when m draws them, and exported as plain rectangles too. No zone/enclosure concept in the schema.
4.4 Wall-outlet IO markers
A cable with to_io_id != NULL exports to an arrow whose endBinding
points to the IO diamond's element ID. The diamond is rendered with a small
IO text label (or m.label if customised). No pair link.
5. Bundle detection — project-scoped
A bundle is a set of cables that physically run together. Bundles never cross projects (a LOFT bundle and an OFFICE bundle are separate).
MVP detection rule, on GET /api/projects/:pid/bundles/suggestions:
Within project :pid, group cables by (from_endpoint, to_endpoint):
from_endpoint = (kind, id) where kind ∈ {port, device, io} and id = whichever *_id is set
to_endpoint = same shape
Treat the endpoint pair as unordered: {A, B} == {B, A}
A candidate suggestion = any group with ≥ 2 cables.
i.e. "two or more cables run between the same two endpoints" → almost certainly a bundle. Types in the group can be mixed (Power + USB + HDMI from desk → wall).
Suggestions are reviewed in the UI; clicking Accept creates a real
bundles row (auto=0). m can also create bundles manually by
shift-clicking cables.
Rendering bundles in the SVG view is a slice 6+ concern; in the export they're ignored in v0 (open question §9).
5b. v4 — Solver
The solver is the headline addition in v4. m's product-vision sentence maps onto it directly:
"I say what devices we have, the app tells me how to bundle cables and how the most efficient connection looks like"
The solver reads a project's devices (with their ports) and
connection_requirements, and writes a set of solver-owned cables
(rows with auto=1) + bundles. m's hand-drawn cables (auto=0) are
left strictly alone — the solver only adds and removes its own.
5b.1 Objective: maximum bundling
Locked in by m. Prefer routes that consolidate cables into shared trunks even at higher total length. Visually cleaner setups; easier to manage physically (one cable bundle along the floor, not five strands).
Concretely: when assigning a cable to a path, the solver minimises the count of distinct trunks, breaking ties by total length. v0 approximates a "trunk" with the pair of device endpoints (the v3 rule); slice 6+ adds path-based trunks via frame-edge corridors.
5b.2 Algorithm (v0)
Pure function. No graph search; no LP. Single pass with greedy port allocation.
solve(project) ⇒ {add, remove, bundles, unsatisfied}:
let auto_cables_before = SELECT * FROM cables WHERE project=p AND auto=1
let port_free := {port_id -> bool} initialised TRUE for every port
minus ports already used by manual cables (auto=0)
for each requirement r in order(must_connect DESC, id ASC):
let ct = r.preferred_cable_type_id
?? auto_pick_cable_type(r.from_device, r.to_device)
?? fail("ambiguous")
let pa = first_free_port(r.from_device, ct, port_free)
let pb = first_free_port(r.to_device, ct, port_free)
if !pa or !pb:
if r.must_connect: unsatisfied.push({r.id, reason})
else: skip
continue
port_free[pa] = port_free[pb] = false
add.push(cable{type=ct, from_port=pa, to_port=pb, auto=1})
// Bundle by endpoint-pair (v3 rule, applied only to auto cables).
for each (device_a, device_b) pair with ≥ 2 add-cables:
bundles_add.push({auto=1, cables: those add-cables})
// Diff against auto_cables_before to compute remove[] (any prior auto
// cable whose (from, to, type) doesn't appear in add[]).
remove = auto_cables_before - add
return {add, remove, bundles_add, unsatisfied}
first_free_port(device, cable_type, free_map) picks the lowest-id port
on the device whose type_id matches and that is still free, returning
NULL if none. The lowest-id tiebreak is deterministic so repeated
solves produce the same plan.
auto_pick_cable_type(from, to) (used when preferred_cable_type_id is
NULL): find the set of cable types T = ports(from).types ∩ ports(to).types. If |T| == 1, return it. If |T| > 1, fail
("ambiguous; specify preferred_cable_type_id"). The UI surfaces this
as a "specify type" inline edit on the requirement.
5b.3 Solver-owned vs. user-owned cables
cables.auto distinguishes them.
| Operation | Effect on auto=0 cables |
Effect on auto=1 cables |
|---|---|---|
| POST /api/.../cables (m draws by hand) | inserts auto=0 | n/a |
| PATCH cables (m moves endpoint, relabels) | applies | applies (and the cable is "promoted" to auto=0 — m owns it now) |
| DELETE cables | applies | applies |
| POST /api/.../solve | left alone (their used ports are reserved before the solver runs) | replaced wholesale (remove[] + add[] in one tx) |
This way a manual cable m doesn't want the solver to second-guess survives every solve. If m wants the solver to take it over, he deletes his hand-drawn cable and re-solves; the solver re-creates an equivalent auto cable.
5b.4 When solver fails
Three classes of failure surface in the response's unsatisfied[]:
- No compatible cable type —
T = ports(from).types ∩ ports(to).typesis empty (e.g. a Power-only device to an HDMI-only device). UX: edit the requirement to specify, or add a port on one of the devices. - Ambiguous cable type —
|T| > 1, no preferred set. UX: pick a type on the requirement. - No free port — the cable type matches but every port on one side is already used. UX: drop a must_connect=0 requirement, or add ports.
The solver does not auto-add ports to a device. Reason: m said "override per instance"; auto-adding crosses that line. The UI surfaces the unmet requirement with a "+ Add port" affordance on the device inspector instead.
5b.5 Preview vs. apply
?preview=1 returns the same shape without writing. The UI shows a diff
modal with add[], remove[], unsatisfied[]; m clicks Apply to fire
the same endpoint without preview=1. Default (no flag) applies
immediately — useful for live-solve mode (open question §9).
6. Sync — export-only for v0
┌─────────────────────┐
│ mCables DB (truth) │
└──────────┬──────────┘
│
export ▼
(push) ┌────────────────────────┐
│ <project>.excalidraw │
│ on mxdrw.msbls.de │
└────────────────────────┘
- mCables UI → DB: synchronous (every drag/add/remove persists immediately).
- DB → Excalidraw: manual button "Export to Excalidraw" in the header,
per project. Calls
POST /api/projects/:pid/sync/export. - Excalidraw → DB: not implemented in v0. Anything m draws in Excalidraw stays in Excalidraw until he redraws it in mCables.
This keeps the v0 scope tight: no conflict resolution, no element-diff import, no auto-debounce. mExDraw keeps its own version history (git sidecar in the mdraw deploy) so a bad export is recoverable from there.
When mxdrw is unreachable: the export button shows a tooltip and disables; the editor keeps working against the local DB.
Post-MVP, import returns as a one-shot migration tool (separate
mcables-migrate CLI tool, not part of the running server) for seeding
new projects from existing .excalidraw files.
7. UI flows
The editor lives at /. Layout:
┌────────────────────────────────────────────────────────────────────┐
│ mCables [LOFT ▾ projects-picker] [Export] [+ Project] │ ← header
├────────┬───────────────────────────────────────────────────────────┤
│ │ │
│ Legend │ │
│ │ │
│ Power │ Diagram surface (SVG) │
│ USB │ │
│ HDMI │ ┌─desk─────────────┐ ┌─rack──────────┐ │
│ DP │ │ [Mac] [Screen] … │ │ [NAS] [fritz] │ │
│ RJ45 │ └──────────────────┘ └───────────────┘ │
│ + Type │ │
│ │ │
│ Tools │ │
│ + Dev │ │
│ + Frm │ │
│ + IO │ │
│ draw │ │
│ │ │
├────────┴───────────────────────────────────────────────────────────┤
│ Inspector (selection-dependent: project / frame / device / port / │
│ cable / bundle details and actions) │
└────────────────────────────────────────────────────────────────────┘
Flow: pick a project
Header has a dropdown "LOFT ▾". Clicking it lists all projects from
GET /api/projects; clicking one swaps the diagram (GET /api/projects/:pid
loads the full snapshot in one round-trip). The picker also shows a
+ New Project action → modal with name, drawing_name (defaults to
<name>.excalidraw), description → POST /api/projects → switches to
the new project (which has 5 seeded cable types and no frames yet).
The currently active project's id is kept in URL state
(/?project=LOFT) so reload returns to the same project.
Flow: add a frame
+ Frmin the left toolbar (orF).- Click + drag on the canvas → rubber-band rectangle becomes a frame.
- Name prompt centered in the frame; Enter →
POST .../frames.
Flow: add a device (v4 — type-aware)
+ Dev(orD) → click on canvas → device placeholder appears.- First field in the inline namer: type dropdown (replaces the
v1 plain-name input). Options pulled from
GET /api/projects/:pid/device-types— built-ins listed first grouped bykind, then project-custom rows, thenCustom (no type). Typing in the dropdown filters byname(m types "n" → NAS jumps to top). Below the dropdown: a name input pre-filled with the type name + a digit if a same-named device already exists ("PC", "PC-2"). - Hit Enter →
POST .../deviceswithtype_id+ name. The server seeds the ports fromdevice_type_portsin the same transaction and returns the device with itsports. - Picking
Custom (no type)keeps the v3 behaviour: rectangle, no ports, m adds ports manually via the inspector. - The device renders with its ports already visible along the configured edge.
Flow: add a port
Select a device → inspector shows + Port button. Click → cursor becomes
a "ghost port" of the active cable type (legend selection). Snap to device
edge → click commits → POST .../devices/:id/ports.
Flow: draw a cable
Click a port → port highlights. Hover any other endpoint (port / device /
IO marker) → preview cable drawn in the source's type colour. Click commits
→ POST .../cables. Shift-click to bind to a whole device. Click an IO
diamond to terminate at a wall outlet.
Flow: add an IO marker (wall outlet)
+ IO (or I) → click on canvas → small diamond placed → optional label
text edit → POST .../io-markers. By design, the only cables that
terminate at an IO marker are Power cables, but the schema doesn't enforce
that — the UI shows a soft warning if m draws a non-Power cable to an IO.
Flow: pick / edit a cable type
Legend on the left is interactive and global (the same legend shows up
in every project). Click a row → that type becomes the active "drawing
type" for the current project's session. Drag the swatch → colour picker →
updates cable_types.color via PATCH /api/cable-types/:id. + Type at
the bottom → "new cable type" modal — POST /api/cable-types. Names are
globally unique.
The modal for editing / adding shows a banner: "Cable types are shared across all projects. Renaming or recolouring affects every project that uses this type." Deleting a type that's still in use by any cable returns a 400 with the offending cable count — the client surfaces it as an inline error in the modal.
Flow: drag a device
Pointer-drag → live transform on the SVG; on pointerup,
PATCH .../devices/:id persists x, y. Ports follow because their
offsets are relative.
Flow: bundles
In the inspector with nothing else selected, "Bundle suggestions" pulls
.../bundles/suggestions. Each suggestion shows the cables highlighted
on the diagram + an Accept button. Manual: shift-click multiple cables →
"Group as bundle" → name it → save.
v4 — Flow: declare connection requirements
The left sidebar gains a Requirements section under the legend:
Cable types
Power, USB, HDMI, DP, RJ45, + Type
Requirements ← new in v4
NAS ↔ Switch RJ45 must
PC ↔ TV HDMI must
Mac ↔ Soundbar HDMI nice
+ Requirement
Click + Requirement → modal with two device pickers (autocomplete from
the project's current devices), a cable-type picker (defaults to
auto-resolve if the device pair has only one matching type), and a
must/nice toggle. POST .../connection-requirements.
Alternative gesture (no tool armed, no selection): drag from device A to device B to seed a requirement modal with the pair pre-filled. The solver-edge preview drags out from the source device's edge in a thin dashed line until release.
m can also right-click a requirement row → edit / delete.
v4 — Flow: run the solver
Header gains a Solve button next to Export.
- Click Solve (or
S) →POST /api/projects/:pid/solve?preview=1. - A diff modal opens listing
add[],remove[],unsatisfied[]— the canvas behind it dims and previews the new cables in a translucent stroke + the to-be-removed cables in a strikethrough red. - Buttons:
- Apply → fires
POST .../solve(nopreview), applies in one transaction, closes the modal, re-renders canvas with the real cables in place. - Cancel → leaves everything as it was.
- Apply → fires
- Unsatisfied requirements get their own list at the bottom of the modal, each with a quick-action button: "Specify type", "+ Add port to device X", or "Drop requirement (set must=0)".
If unsatisfied[] is non-empty, the Solve button stays in a
soft-error state (yellow) until either every requirement is satisfiable
or m explicitly accepts the partial plan.
v4 — Inspector states
| Selection | Inspector shows |
|---|---|
| nothing | empty, with "Bundle suggestions" + "Project requirements" headlines |
| project header | name, drawing_name, description (editable), device count, requirement count, Solve / Export buttons |
| frame | name (editable), x/y/w/h, contained-device count, delete |
| device | name + type + icon, ports grid (type / label / connected? / +Port), unmet requirements list (red badges with quick-fix), delete |
| port | type, label, parent device, current cable (if any), delete |
| cable (auto=1) | source/target, type, driving requirement (clickable → opens requirement edit), parent bundle (if any), label, "Promote to manual" (sets auto=0) |
| cable (auto=0) | as v3 — type, source/target, label, delete |
| bundle | name, member cables (clickable to focus), trunk segment description, auto-detected flag |
Keyboard
P switch project, F add frame, D add device, I add IO marker,
T start cable from selected port, R add requirement,
S solve project (v4), E export, Esc cancel, Backspace delete
selection, ? show shortcuts.
8. First slices — v4 reshape
Slices 1 + 2 have shipped (see git history). v4 inserts new slices ahead of the original 3-5 because the solver depends on the catalog + the requirements model, not on manual cable drawing. The old "manual port + cable draw" slice is still in scope as a tweak path on the solver output, but it follows the solver instead of leading.
| # | Slice | Status | What's shipped |
|---|---|---|---|
| 1 | Bootstrap + project CRUD + global cable_types | ✅ shipped | See git: branch mai/picasso/slice-1-bootstrap. |
| 2 | Frames + devices + drag | ✅ shipped | See git: branch mai/picasso/slice-2-frames-devices. |
| 3 (was 4) | IO markers + cable-type editing | pending | Unchanged scope. + IO places a wall-outlet diamond. Legend swatch is a colour picker; renaming a type updates the legend on the fly. + Type adds new global types. |
| 4 (NEW) | Device-type catalog + type-aware device create | pending | Migration 002: device_types + device_type_ports, seeded with the 11 built-ins (§2.2). Migration adds devices.type_id. API: GET /api/device-types, GET /api/projects/:pid/device-types. Frontend: the +Dev inline namer becomes a type dropdown + name input; choosing a built-in type seeds the device's ports on the backend. Picking Custom (no type) falls back to v3 freeform. m can create a typed NAS + see its Power + RJ45 ports appear on the canvas. |
| 4.5 (NEW) | Manage device-type catalog (per project) | pending | Modal: POST/PATCH/DELETE /api/projects/:pid/device-types for project-custom rows. Edit affordance hidden for built-ins. Lets m add an exotic device type without contributing to the built-in catalog. Validation: a custom type can't share a name with a built-in (already enforced by UNIQUE(project_id, name) + a separate code-level check against built-ins). |
| 5 (NEW) | Connection requirements UI + CRUD | pending | Migration 003: connection_requirements. API: full CRUD under /api/projects/:pid/connection-requirements. Frontend: left-sidebar "Requirements" section, + Requirement modal (autocomplete from project's current devices, cable-type picker, must/nice toggle). Drag from device A to device B gestures the same modal pre-filled. Inspector for a selected device lists its requirements. |
| 6 (NEW) | Solver MVP + Solve button | pending | POST /api/projects/:pid/solve with ?preview=1 support. v0 algorithm (§5b.2): pure-function, greedy port allocation, endpoint-pair bundling (slice 6.5 is path-based bundling). Migration adds cables.auto. Header gains a Solve button that opens the preview-diff modal. m clicks Solve → sees the cable plan → applies. |
| 7 (was 3, slimmed) | Manual port + manual cable draw | pending | The v3 flow as a tweak path on solver output. + Port on an instance-owned device; click-port → click-port creates a hand-drawn cable (auto=0). Used to override the solver's choices or to extend its plan. |
| 8 (was 5) | Export to mxdrw.msbls.de | pending | POST .../sync/export writes a .excalidraw scene per the visual grammar (§4). Bundles ignored on export in v0. |
Slices 9+ (not promised for the first coder shift):
- Path-based bundling: instead of endpoint-pair bundling, group cables that share a frame-edge corridor or a wall-axis (§5b.1 "trunk segment" definition).
- Live-solve mode: re-run solver on every device/requirement edit with a debounce + previewed-but-not-applied diff in a toast.
- Setup templates (Living Room, Home Office, Server Rack): a
setup_templatestable +POST .../apply-templatethat pre-populatesconnection_requirementsfrom an archetype. - Bundle rendering in the SVG (thick path with mixed-colour fan-out) and in the export.
- "Re-seed from type" action on a device.
- Cable inventory metadata (length/SKU) if m later wants it.
- Dark mode.
9. Open questions for m — v4
v3 closed all its v2 questions. v4 raises six new ones, all about the solver semantics and UX. Worth resolving before slice 4 starts so the coder shift doesn't backtrack:
-
Where do paths come from? v0 draws straight lines port-to-port + bundles by endpoint-pair. Three candidates for slice 6.5/9: (a) auto-route through frame edges (cables exit a device toward the nearest frame edge, traverse along edges, enter the target frame); (b) m draws cable-tray polylines on the canvas and cables snap to them; (c) Steiner-tree-ish path optimisation per trunk. I lean (b) + (a) as fallback — m gets the manual override when his layout is non-obvious, otherwise the system routes for him. Confirm direction.
-
Live solve or button-only? Two modes available:
- Button-only (locked default) — m hits Solve, sees the diff, applies. Simple; no surprises.
- Live — solver re-runs on every device/requirement edit with a debounce, results land in a toast "12 cables, 3 bundles, 1 unmet — review?". More responsive, costs ~10ms of compute per edit. I'd ship button-only first (slice 6); add live as an opt-in toggle (slice 9+). Confirm or escalate to "live always".
-
No-compatible-port-pair UX when solving. Three options: (a) Surface as "unsatisfiable" and let m manually add a port via the device inspector (current §5b.4 stance). (b) Auto-add the missing port to the device on the m's confirmation (single-button "Add HDMI to PC and re-solve"). (c) Auto-add silently — bad UX, surprise mutations. I lean (a) with a one-click quick-fix that does (b). Confirm.
-
Setup templates — v4 or post-MVP? "Living Room" / "Home Office" / "Server Rack" archetypes that pre-populate
connection_requirements. I left this out of the v4 slice list (designed in §8 "slices 9+") to keep v4 tight. m can build the same effect by adding requirements manually first time. Confirm: post-MVP OK, or do you want me to fold it into slice 5/6? -
Catalog distribution: code seed vs. JSON file. Two paths: (a) seed in migration 002 via SQL INSERTs (today's design — locked-in, schema versioned, no user override of built-ins). (b) seed from a curated JSON file at
internal/db/catalog/builtin.jsonthat the migration reads. m or contributors can extend the file by PR; rebuild image; new built-ins appear. I lean (a) for v0 (simpler, doesn't need a file-loader). Open question: do you anticipate growing the built-in list often? If yes, (b) starts paying off after the second addition. -
Promoting a solver cable to manual. §5b.3 says PATCHing an
auto=1cable flips it toauto=0so the next solve doesn't replace it. Two surface variants: (a) Implicit: any PATCH that touches type/from/to on an auto cable promotes it. m never sees the flag. (b) Explicit: a "Promote to manual" button on the cable inspector (current §7 stance). PATCHes that only update labels stay auto. I leaned (b) for clarity ("the solver might overwrite this — promote to protect"). Confirm or override.
10. Deployment on mDock (raw docker)
Inspected mDock's live services on 2026-05-15 to lock the conventions before writing this:
- All m-built services on mDock live under
/home/m/stacks/<project>/with a singledocker-compose.yml. Older services in/home/m/<project>/use the same pattern; the canonical-new path isstacks/. - Compose v2 (
docker compose), images built from Gitea container registry (mgit.msbls.de/m/<project>:latest),restart: unless-stoppedon every service,container_name: <project>explicit. - Host port mappings: deliberately collision-free across the host. Existing high ports in use include 3300 (mgreen), 3077 (paperless-ai), 7878 (radarr), 8082 (mgeo-tileserver), 8989 (sonarr), 9696 (prowlarr). Port 7777 is free — taking it for mCables.
- Bind-mount volumes:
/home/m/<project>-data:/app/datais the canonical pattern (mgreen). For project-local data we putdata/next to the compose file so agit pull && docker compose up -dis the whole deploy:/home/m/stacks/mcables/data:/app/data. - Secrets via
env_file: /home/m/secrets/<project>/.env(msports-garmin pattern). mCables only needsMEXDRAW_TOKENfor export. - No reverse proxy on mDock. Services expose ports directly on the LAN
(mDock =
192.168.178.131/ Tailscalemdock). Public exposure goes via mlake/Dokploy + Caddy when needed — out of scope for mCables (LAN-only). - Auto-deploy via the Gitea Actions self-hosted runner already installed
on mDock (
/home/m/act-runner/, labelself-hosted:host). Push tomain→ workflow on mDock →docker compose up --build -d.
Repo layout for mCables
mCables/
├── cmd/mcables/main.go # Go binary
├── internal/
│ ├── db/ # migrations + store
│ ├── importer/ # post-MVP only (not in MVP)
│ ├── exporter/ # DB → .excalidraw
│ └── server/ # net/http handlers
├── web/ # embedded static frontend
│ ├── index.html
│ ├── main.js # ES module entry
│ ├── style.css
│ └── lib/... # SVG helpers, store, components
├── data/ # mCables runtime DB lives here (gitignored)
│ └── .gitkeep
├── docs/design.md # this file
├── Dockerfile
├── docker-compose.yml
├── .gitea/workflows/deploy.yml
├── .gitignore # data/, *.db, *.db-wal, *.db-shm
├── Makefile # build, typecheck, test, run
├── go.mod / go.sum
└── README.md
Dockerfile sketch
Multi-stage; the final image is scratch because modernc.org/sqlite is
pure Go.
# syntax=docker/dockerfile:1.7
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \
-o /out/mcables ./cmd/mcables
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /out/mcables /app/mcables
ENV MCABLES_ADDR=0.0.0.0:7777
ENV MCABLES_DB=/app/data/mcables.db
USER nonroot:nonroot
EXPOSE 7777
ENTRYPOINT ["/app/mcables"]
docker-compose.yml (on mDock at /home/m/stacks/mcables/)
services:
mcables:
image: mgit.msbls.de/m/mcables:latest
container_name: mcables
restart: unless-stopped
ports:
- "7777:7777"
environment:
- TZ=Europe/Berlin
- MCABLES_ADDR=0.0.0.0:7777
- MCABLES_DB=/app/data/mcables.db
- MEXDRAW_BASE_URL=https://mxdrw.msbls.de
env_file:
- /home/m/secrets/mcables/.env # contains MEXDRAW_TOKEN
volumes:
- /home/m/stacks/mcables/data:/app/data
LAN URL: http://mdock:7777 (or http://192.168.178.131:7777).
Gitea Actions deploy workflow
.gitea/workflows/deploy.yml:
name: deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t mgit.msbls.de/m/mcables:latest .
- name: Push image
run: |
echo "${{ secrets.GITEA_TOKEN }}" | \
docker login mgit.msbls.de -u mAi --password-stdin
docker push mgit.msbls.de/m/mcables:latest
- name: Up
run: |
cd /home/m/stacks/mcables
docker compose pull
docker compose up -d
Local-development run (no Docker)
make run # go run ./cmd/mcables → :7777 against ./data/mcables.db
make typecheck # tsc --noEmit on web/
make test # go test ./...
The repo has data/ checked-in-empty (with .gitkeep); data/*.db* is
gitignored.
DESIGN v4 READY FOR REVIEW