From d6ec9b693a5305061ec9753118dfb0e6849c721d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 23:13:51 +0000 Subject: [PATCH] Cables and circuits: edit mode, route drawing, cores and circuit colours - Edit API that writes points, cable routes and signal links back into the hand-written YAML files, keeping comments; optional git commit per edit - Edit mode in the app: drag and place points, draw cable routes, link PLC channels to existing or new points - Colour the plan by circuit, with a legend; a breaker lists what it feeds - Cable cores with their terminals; a cable shows on every port a core touches Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017KyUdSpsGYq6NV2mMmxJBv --- Dockerfile | 4 +- README.md | 31 +++- docker-compose.yml | 7 + examples/demo-house/cables.yaml | 6 + examples/demo-house/circuits.yaml | 24 ++++ examples/demo-house/devices.yaml | 26 ++++ schema/devices.schema.json | 2 +- server/src/app.ts | 77 ++++++++++ server/src/checks.ts | 4 + server/src/circuits.ts | 34 +++++ server/src/edit.ts | 221 ++++++++++++++++++++++++++++ server/src/index.ts | 9 +- server/src/load.ts | 4 +- server/src/trace.ts | 10 +- server/test/edit.test.ts | 115 +++++++++++++++ web/src/App.svelte | 231 ++++++++++++++++++++++++++++-- web/src/app.css | 20 +++ web/src/lib/Details.svelte | 104 +++++++++++++- web/src/lib/FloorPlan.svelte | 92 +++++++++++- web/src/lib/LinkSignal.svelte | 93 ++++++++++++ web/src/lib/api.ts | 45 +++++- web/src/lib/colours.ts | 31 ++++ 22 files changed, 1159 insertions(+), 31 deletions(-) create mode 100644 server/src/circuits.ts create mode 100644 server/src/edit.ts create mode 100644 server/test/edit.test.ts create mode 100644 web/src/lib/LinkSignal.svelte create mode 100644 web/src/lib/colours.ts diff --git a/Dockerfile b/Dockerfile index e6f538f..37595e2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,8 @@ COPY web web RUN npm run build && npm prune --omit=dev FROM node:22-alpine +# git lets HADOC_GIT_COMMIT=1 commit each edit in the data folder. +RUN apk add --no-cache git ENV NODE_ENV=production \ HADOC_DATA=/data \ PORT=8080 @@ -22,7 +24,7 @@ COPY --from=build /app/schema schema COPY --from=build /app/server/package.json server/ COPY --from=build /app/server/dist server/dist COPY --from=build /app/web/dist web/dist -COPY examples/demo-house /data +COPY --chown=node:node examples/demo-house /data USER node EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1:8080/api/health || exit 1 diff --git a/README.md b/README.md index 56edc6a..9f0bb7d 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,14 @@ HAdoc keeps floor plans, cabling, power circuits, controller I/O (PLC channels, ESP pins) and the link to your home automation software in one place, as plain YAML files you keep in git. -> Status: early scaffold. The viewer, data format, validation and trace work; editing in the app, importers and the AREI diagrams are on the [roadmap](#roadmap). +> Status: early. The viewer, data format, validation, trace, WAGO import and editing in the app work; the other importers and the AREI diagrams are on the [roadmap](#roadmap). ## What you get - **Floor plan** per level, with rooms, points and cable routes. Pinch and drag on a phone, scroll and drag on a desktop. -- **Layers** for power, automation, data, hidden things and cables. +- **Layers** for power, automation, data, hidden things and cables, and **colour by circuit** to see what each breaker feeds. +- **Cores and terminals**: list the cores of a cable and the terminal each one lands on; the cable then shows up on every port it touches. +- **Edit mode**: drag points, place new ones, draw cable routes and link PLC channels to points. Edits are written back into your YAML files (comments and layout kept) and can be committed to git. - **Tap anything** to see its trace: the circuit and breaker that feed it, the cables and terminals it lands on, the device or PLC channel behind it, and the MQTT topic or openHAB item that reads it. - **"If it stops working"** notes in plain language, for everyone in the house. - **Checks** that flag broken references, IDs that don't follow your scheme, and what is still undocumented. @@ -106,6 +108,23 @@ node server/dist/cli.js validate path/to/house Errors (broken references, schema violations, IDs that contradict their fields) exit with code 1, so this works as a pre-commit hook or CI step in your data repo. Warnings list what is still undocumented. +## Editing in the app + +Tap **Edit** in the top bar. Then you can: + +- drag a point to move it, or use **Place on the plan** for a point that has no position yet; +- open a cable from a point or device and tap **Draw route**, then tap its corners on the plan (tapping a point snaps to it); +- link an unlinked PLC channel to a point from the device's port list, or create the point there and place it. + +Edits go into the hand-written file that holds the record; imported records get an overlay entry in `signals.yaml`. New points go into the file that already has points of that level, else `points.yaml`. The number is the next free one for that level, room and type. + +| Variable | Effect | +| --- | --- | +| `HADOC_READONLY=1` | Turns editing off; the Edit button disappears. | +| `HADOC_GIT_COMMIT=1` | Commits every edit to the git repository in the data folder, as `HAdoc ` unless `GIT_AUTHOR_NAME` and `GIT_AUTHOR_EMAIL` are set. | + +In Docker the app runs as uid 1000, so the data folder must be writable for that user. There is no login yet: anyone who can reach the app can edit, so keep it on your LAN or behind your reverse proxy's authentication, or set `HADOC_READONLY=1`. + ## API | Endpoint | Returns | @@ -114,14 +133,18 @@ Errors (broken references, schema violations, IDs that contradict their fields) | `GET /api/trace/:id` | The trace for a point, device or enclosure | | `GET /api/issues` | Every error and warning | | `GET /api/health` | Liveness and when the data was last loaded | +| `PATCH /api/points/:id` | Move a point: `{ "pos": [x, y] }` or `{ "pos": null }` | +| `POST /api/points` | New point: `{ type, level, room, name?, pos?, signal? }`; `signal` links that signal to it | +| `PUT /api/cables/:id/route` | Route on one floor: `{ level, path: [[x, y], …] }`, or `path: null` to remove it | +| `PATCH /api/signals/:id` | Link a signal to a point: `{ "point": "0-HAL-SW-01" }` or `null` | The server reloads the data when files in the data folder change. ## Roadmap -1. **MVP**: plan underlay, rooms, points, point pages, trace, checks *(this scaffold)*. Next: placing and editing points in the app, with each change committed to git. +1. **MVP**: plan underlay, rooms, points, point pages, trace, checks *(done)*. 2. **WAGO import** from the CODESYS 2.3 PLC configuration export *(done)*. -3. **Cables and circuits**: drawing routes, terminals and cores, breaker colouring. +3. **Cables and circuits**: drawing routes, terminals and cores, breaker colouring, editing in the app with git commits *(done)*. 4. **Importers**: openHAB, UniFi (status and IP), ESPHome, Tasmota, MQTT, plus the "undocumented" report and the rules behind each item. 5. **PLC logic viewer** from POU exports, wall and enclosure views, QR labels. 6. **AREI** single-line diagram (eendraadschema) and situation sketch (situatieschets). diff --git a/docker-compose.yml b/docker-compose.yml index 67a5169..1870c41 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,3 +9,10 @@ services: - "8090:8080" volumes: - ./house:/data + environment: + # Commit every edit made in the app to the git repository in ./house. + HADOC_GIT_COMMIT: "1" + # Set to "1" to turn editing off. + HADOC_READONLY: "0" + # The app writes edits as the "node" user (uid 1000); ./house must be writable for it. + user: "1000:1000" diff --git a/examples/demo-house/cables.yaml b/examples/demo-house/cables.yaml index e3aa6c1..fde87ae 100644 --- a/examples/demo-house/cables.yaml +++ b/examples/demo-house/cables.yaml @@ -4,6 +4,12 @@ type: SVV 4x0.8 from: 0-HAL-SW-01 to: "plc-di01:DI 01.1" + # Double switch: one core per button, plus the 24 V feed. + cores: + - {colour: white, from: 0-HAL-SW-01, to: "plc-di01:DI 01.1"} + - {colour: brown, from: 0-HAL-SW-01, to: "plc-di01:DI 01.2"} + - {colour: green, from: 0-HAL-SW-01, to: "X1:1"} + - {colour: yellow, from: null, to: null} route: - level: "0" path: [[40, 760], [20, 760], [20, 20]] diff --git a/examples/demo-house/circuits.yaml b/examples/demo-house/circuits.yaml index fb75bfd..ef8a286 100644 --- a/examples/demo-house/circuits.yaml +++ b/examples/demo-house/circuits.yaml @@ -1,4 +1,20 @@ # yaml-language-server: $schema=../../schema/circuits.schema.json +- id: F1 + name: Sockets ground floor + breaker: F1 + rating: C16 + rcd: RCD1 + phase: L1 + feeds: [0-KIT-SKT-01] + +- id: F2 + name: Lights ground floor + breaker: F2 + rating: C10 + rcd: RCD1 + phase: L2 + feeds: [0-HAL-LGT-01] + - id: F3 name: Lights first floor breaker: F3 @@ -6,3 +22,11 @@ rcd: RCD1 phase: L1 feeds: [1-DRS-LGT-01, sonoff-mirror, 1-BED-BLD-01] + +- id: F4 + name: PLC cabinet + breaker: F4 + rating: C6 + rcd: RCD1 + phase: L3 + feeds: [plc] diff --git a/examples/demo-house/devices.yaml b/examples/demo-house/devices.yaml index a5a85bd..e1fa759 100644 --- a/examples/demo-house/devices.yaml +++ b/examples/demo-house/devices.yaml @@ -24,11 +24,37 @@ firmware: Tasmota hostname: sonoff-mirror +- id: X1 + kind: terminal-block + label: X1 + model: 24 V DC distribution + location: plc-cabinet + ports: + - { name: "1", signal: +24V } + - { name: "2", signal: +24V } + - { name: "3", signal: +24V } + - { name: "4", signal: +24V } + +- id: F1 + kind: breaker + model: C16 + location: board-main + +- id: F2 + kind: breaker + model: C10 + location: board-main + - id: F3 kind: breaker model: C16 location: board-main +- id: F4 + kind: breaker + model: C6 + location: board-main + - id: RCD1 kind: rcd model: 30 mA type A diff --git a/schema/devices.schema.json b/schema/devices.schema.json index 8450321..46522f4 100644 --- a/schema/devices.schema.json +++ b/schema/devices.schema.json @@ -10,7 +10,7 @@ "required": ["id", "kind"], "properties": { "id": { "type": "string" }, - "kind": { "enum": ["plc", "io-module", "esp", "relay", "psu", "breaker", "rcd", "switch", "other"] }, + "kind": { "enum": ["plc", "io-module", "esp", "relay", "psu", "breaker", "rcd", "switch", "terminal-block", "other"] }, "model": { "type": ["string", "null"] }, "label": { "type": "string", "description": "Physical label, e.g. 'DI 01'." }, "location": { "type": "string", "description": "Point or enclosure where the device sits." }, diff --git a/server/src/app.ts b/server/src/app.ts index 0453b9a..88aef41 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -2,6 +2,8 @@ import { existsSync, watch } from "node:fs"; import path from "node:path"; import Fastify from "fastify"; import fastifyStatic from "@fastify/static"; +import { circuitOf } from "./circuits.js"; +import { EditError, editor } from "./edit.js"; import { indexHouse, type HouseIndex } from "./index-house.js"; import { loadHouse } from "./load.js"; import type { House } from "./model.js"; @@ -14,8 +16,14 @@ export interface AppOptions { /** Reload the data when files in the data folder change. */ watch?: boolean; logger?: boolean; + /** Refuse edits from the app, e.g. when the data folder is mounted read-only. */ + readOnly?: boolean; + /** Commit every edit in the data folder's git repository. */ + gitCommit?: boolean; } +const xy = { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2 } as const; + export async function buildApp(options: AppOptions) { const app = Fastify({ logger: options.logger ?? false }); const dataDir = path.resolve(options.dataDir); @@ -46,6 +54,8 @@ export async function buildApp(options: AppOptions) { const { issues, ...rest } = house; return { ...rest, + circuitOf: circuitOf(house, index), + editable: !options.readOnly, issueCount: { errors: issues.filter((i) => i.severity === "error").length, warnings: issues.filter((i) => i.severity === "warning").length, @@ -61,6 +71,73 @@ export async function buildApp(options: AppOptions) { return trace; }); + if (!options.readOnly) { + const edit = editor(dataDir, () => house, reload, { commit: options.gitCommit }); + + app.setErrorHandler((err, _req, reply) => { + if (err instanceof EditError) return reply.code(err.status).send({ error: err.message }); + const code = (err as NodeJS.ErrnoException).code; + if (code === "EACCES" || code === "EROFS" || code === "EPERM") { + return reply.code(403).send({ error: "The data folder is not writable; check the volume mount and its owner" }); + } + return reply.send(err); + }); + + app.patch<{ Params: { id: string }; Body: { pos: [number, number] | null } }>( + "/api/points/:id", + { schema: { body: { type: "object", required: ["pos"], properties: { pos: { anyOf: [xy, { type: "null" }] } } } } }, + async (req) => edit.movePoint(req.params.id, req.body.pos), + ); + + app.post<{ + Body: { type: string; level: string; room: string; name?: string; pos?: [number, number] | null; signal?: string }; + }>( + "/api/points", + { + schema: { + body: { + type: "object", + required: ["type", "level", "room"], + properties: { + type: { type: "string" }, + level: { type: "string" }, + room: { type: "string" }, + name: { type: "string" }, + pos: { anyOf: [xy, { type: "null" }] }, + signal: { type: "string" }, + }, + }, + }, + }, + async (req) => { + const { signal, ...point } = req.body; + const created = await edit.createPoint(point); + if (signal) await edit.linkSignal(signal, created.id); + return created; + }, + ); + + app.put<{ Params: { id: string }; Body: { level: string; path: [number, number][] | null } }>( + "/api/cables/:id/route", + { + schema: { + body: { + type: "object", + required: ["level", "path"], + properties: { level: { type: "string" }, path: { anyOf: [{ type: "array", items: xy, minItems: 2 }, { type: "null" }] } }, + }, + }, + }, + async (req) => edit.setRoute(req.params.id, req.body.level, req.body.path), + ); + + app.patch<{ Params: { id: string }; Body: { point: string | null } }>( + "/api/signals/:id", + { schema: { body: { type: "object", required: ["point"], properties: { point: { type: ["string", "null"] } } } } }, + async (req) => edit.linkSignal(req.params.id, req.body.point), + ); + } + // Plan underlays and photos live in the data folder. for (const dir of ["plans", "photos"]) { const root = path.join(dataDir, dir); diff --git a/server/src/checks.ts b/server/src/checks.ts index 0d3b1d6..e22fe08 100644 --- a/server/src/checks.ts +++ b/server/src/checks.ts @@ -112,6 +112,10 @@ export function checkHouse(house: House): Issue[] { mustResolve("cables.yaml", c.id, "to", c.to); if (c.from == null || c.to == null) warn("cables.yaml", c.id, "One end of the cable is not documented"); if (c.type == null) warn("cables.yaml", c.id, "Cable type is unknown"); + for (const [i, core] of (c.cores ?? []).entries()) { + mustResolve("cables.yaml", c.id, `cores[${i}].from`, core.from); + mustResolve("cables.yaml", c.id, `cores[${i}].to`, core.to); + } for (const segment of c.route ?? []) { if (!levels.has(segment.level)) error("cables.yaml", c.id, `Route level "${segment.level}" is not in settings.levels`); } diff --git a/server/src/circuits.ts b/server/src/circuits.ts new file mode 100644 index 0000000..554f12b --- /dev/null +++ b/server/src/circuits.ts @@ -0,0 +1,34 @@ +import type { HouseIndex } from "./index-house.js"; +import type { House } from "./model.js"; + +/** + * The circuit behind every point and device that has one: fed directly, through + * the device it holds, or up a powered_by chain. Used to colour the plan by breaker. + */ +export function circuitOf(house: House, index: HouseIndex): Record { + const fed = new Map(); + for (const c of house.circuits) for (const f of c.feeds) if (!fed.has(index.thingId(f))) fed.set(index.thingId(f), c.id); + + const forDevice = (id: string | null | undefined, seen = new Set()): string | undefined => { + if (!id || seen.has(id)) return undefined; + seen.add(id); + if (fed.has(id)) return fed.get(id); + if (index.circuits.has(id)) return id; + const thing = index.things.get(id); + return thing?.kind === "device" && thing.item.powered_by ? forDevice(index.thingId(thing.item.powered_by), seen) : undefined; + }; + + const result: Record = {}; + for (const d of house.devices) { + const c = forDevice(d.id); + if (c) result[d.id] = c; + } + for (const p of house.points) { + const c = + fed.get(p.id) ?? + (p.device ? result[p.device] : undefined) ?? + house.devices.filter((d) => d.location === p.id).map((d) => result[d.id]).find(Boolean); + if (c) result[p.id] = c; + } + return result; +} diff --git a/server/src/edit.ts b/server/src/edit.ts new file mode 100644 index 0000000..15b3716 --- /dev/null +++ b/server/src/edit.ts @@ -0,0 +1,221 @@ +// Edits from the app, written back into the hand-written YAML files. +// The yaml Document API keeps comments, key order and formatting of everything +// that is not touched. Imported records are never written; an edit to one +// becomes an overlay entry in the hand-written file instead. + +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; +import { Document, isMap, isSeq, parseDocument, type YAMLMap, type YAMLSeq } from "yaml"; +import { idScheme } from "./ids.js"; +import { listFiles, type ListKey } from "./load.js"; +import type { House, XY } from "./model.js"; + +export class EditError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + } +} + +interface Found { + file: string; + doc: Document; + item: YAMLMap; +} + +async function openDoc(dataDir: string, file: string): Promise { + const full = path.join(dataDir, file); + if (!existsSync(full)) { + const doc = new Document([]); + doc.commentBefore = ` yaml-language-server: $schema=${path.posix.relative(path.posix.dirname(file), "schema")}/${file.split("/")[0]!.replace(/\.yaml$/, "")}.schema.json`; + return doc; + } + const doc: Document = parseDocument(await readFile(full, "utf8")); + if (doc.errors.length) throw new EditError(409, `${file} has YAML errors; fix them first`); + if (doc.contents === null) doc.contents = doc.createNode([]); + if (!isSeq(doc.contents)) throw new EditError(409, `${file} is not a list`); + return doc; +} + +async function find(dataDir: string, list: ListKey, id: string): Promise { + for (const file of await listFiles(dataDir, list)) { + const doc = await openDoc(dataDir, file); + const item = (doc.contents as YAMLSeq).items.find((i) => isMap(i) && i.get("id") === id); + if (item) return { file, doc, item: item as YAMLMap }; + } + return null; +} + +async function save(dataDir: string, file: string, doc: Document) { + await writeFile(path.join(dataDir, file), doc.toString({ lineWidth: 0, flowCollectionPadding: false }), "utf8"); +} + +function flow(doc: Document, value: unknown) { + const node = doc.createNode(value); + if (isSeq(node)) { + node.flow = true; + for (const child of node.items) if (isSeq(child)) child.flow = true; + } + return node; +} + +/** Appends a record to a list file, separated from the previous one by a blank line. */ +function append(doc: Document, record: Record) { + const node = doc.createNode(record) as YAMLMap; + for (const pair of node.items) { + if (Array.isArray(record[String(pair.key)])) pair.value = flow(doc, record[String(pair.key)]); + } + const seq = doc.contents as YAMLSeq; + if (seq.items.length) node.spaceBefore = true; + seq.items.push(node); +} + +const run = promisify(execFile); + +/** Commits one file in the data folder's git repository. Failing to commit never fails the edit. */ +async function commit(dataDir: string, file: string, message: string): Promise { + const name = process.env.GIT_AUTHOR_NAME ?? "HAdoc"; + const email = process.env.GIT_AUTHOR_EMAIL ?? "hadoc@localhost"; + const env = { ...process.env, GIT_AUTHOR_NAME: name, GIT_AUTHOR_EMAIL: email, GIT_COMMITTER_NAME: name, GIT_COMMITTER_EMAIL: email }; + // The volume is often owned by another user than the container's; trust it explicitly. + const git = (...args: string[]) => run("git", ["-c", `safe.directory=${dataDir}`, "-C", dataDir, ...args], { env }); + try { + await git("add", "--", file); + await git("commit", "-q", "-m", message, "--", file); + return true; + } catch { + return false; + } +} + +const roundXY = (p: XY): XY => [Math.round(p[0]), Math.round(p[1])]; + +/** + * `house` returns the current data and `reload` refreshes it; both run inside the + * write queue, so every edit sees the result of the one before. + */ +export function editor( + dataDir: string, + house: () => House, + reload: () => Promise, + options: { commit?: boolean } = {}, +) { + // One write at a time, so two quick edits cannot overwrite each other. + let queue: Promise = Promise.resolve(); + const serial = (message: string, fn: () => Promise<{ id: string; file: string }>) => { + const task = async () => { + const result = await fn(); + const committed = options.commit ? await commit(dataDir, result.file, message.replace("{id}", result.id)) : false; + await reload(); + return { ...result, committed }; + }; + const run = queue.then(task, task); + queue = run.catch(() => undefined); + return run; + }; + + return { + /** Moves a point, or clears its position with null. */ + movePoint: (id: string, pos: XY | null) => + serial(pos ? `Move {id}` : `Remove {id} from the plan`, async () => { + const found = await find(dataDir, "points", id); + if (!found) throw new EditError(404, `Point "${id}" is not in a points file`); + found.item.set("pos", pos ? flow(found.doc, roundXY(pos)) : null); + await save(dataDir, found.file, found.doc); + return { id, file: found.file }; + }), + + /** Sets the route of a cable on one floor, or removes it with null. */ + setRoute: (id: string, level: string, route: XY[] | null) => + serial(route ? `Draw route of {id} on level ${level}` : `Remove route of {id} on level ${level}`, async () => { + const found = await find(dataDir, "cables", id); + if (!found) throw new EditError(404, `Cable "${id}" is not in a cables file`); + const { doc, item } = found; + let seq = item.get("route") as YAMLSeq | undefined; + if (!isSeq(seq)) { + seq = doc.createNode([]) as YAMLSeq; + item.set("route", seq); + } + const at = seq.items.findIndex((r) => isMap(r) && String(r.get("level")) === level); + if (route) { + const entry = doc.createNode({ level }) as YAMLMap; + entry.set("path", flow(doc, route.map(roundXY))); + if (at === -1) seq.items.push(entry); + else (seq.items[at] as YAMLMap).set("path", entry.get("path", true)); + } else if (at !== -1) { + seq.items.splice(at, 1); + if (seq.items.length === 0) item.delete("route"); + } + await save(dataDir, found.file, doc); + return { id, file: found.file }; + }), + + /** + * Creates a point with the next free number for its level, room and type. It is + * added to the file that already holds points of that level, else points.yaml. + */ + createPoint: (input: { type: string; level: string; room: string; name?: string; pos?: XY | null }) => + serial(`Add point {id}`, async () => { + const { type, level, room } = input; + const { settings, site, points } = house(); + if (!settings.types.some((t) => t.code === type)) throw new EditError(400, `Unknown type "${type}"`); + const floor = site.floors.find((f) => f.level === level); + if (!floor) throw new EditError(400, `No floor for level "${level}"`); + if (!floor.rooms.some((r) => r.code === room)) throw new EditError(400, `No room "${room}" on level "${level}"`); + + const scheme = idScheme(settings); + let nr = 0; + for (const p of points) { + const parts = scheme.parse(p.id); + if (parts && parts.level === level && parts.room === room && parts.type === type) nr = Math.max(nr, Number(parts.nr)); + } + const id = scheme.format({ level, room, type, nr: nr + 1 }); + + let file = "points.yaml"; + for (const f of await listFiles(dataDir, "points")) { + const doc = await openDoc(dataDir, f); + if ((doc.contents as YAMLSeq).items.some((i) => isMap(i) && String(i.get("level")) === level)) { + file = f; + break; + } + } + const doc = await openDoc(dataDir, file); + append(doc, { + id, + type, + level, + room, + ...(input.name ? { name: input.name } : {}), + pos: input.pos ? roundXY(input.pos) : null, + }); + await save(dataDir, file, doc); + return { id, file }; + }), + + /** Links a signal to a point (or unlinks it with null). Imported signals get an overlay entry. */ + linkSignal: (id: string, point: string | null) => + serial(point ? `Link ${id} to ${point}` : `Unlink ${id}`, async () => { + const { signals, points } = house(); + if (!signals.some((s) => s.id === id)) throw new EditError(404, `Signal "${id}" does not exist`); + if (point !== null && !points.some((p) => p.id === point)) throw new EditError(400, `Point "${point}" does not exist`); + const found = await find(dataDir, "signals", id); + if (found) { + found.item.set("point", point); + await save(dataDir, found.file, found.doc); + return { id, file: found.file }; + } + const file = "signals.yaml"; + const doc = await openDoc(dataDir, file); + append(doc, { id, point }); + await save(dataDir, file, doc); + return { id, file }; + }), + }; +} + +export type Editor = ReturnType; diff --git a/server/src/index.ts b/server/src/index.ts index 38b800b..64535ad 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -7,5 +7,12 @@ const webDir = process.env.HADOC_WEB ?? fileURLToPath(new URL("../../web/dist/", const port = Number(process.env.PORT ?? 8080); const host = process.env.HOST ?? "0.0.0.0"; -const app = await buildApp({ dataDir, webDir, watch: true, logger: true }); +const app = await buildApp({ + dataDir, + webDir, + watch: true, + logger: true, + readOnly: process.env.HADOC_READONLY === "1", + gitCommit: process.env.HADOC_GIT_COMMIT === "1", +}); await app.listen({ port, host }); diff --git a/server/src/load.ts b/server/src/load.ts index 4214e6d..65d6f03 100644 --- a/server/src/load.ts +++ b/server/src/load.ts @@ -18,7 +18,7 @@ const OBJECTS = ["settings", "site"] as const; */ const LISTS = ["points", "enclosures", "devices", "cables", "circuits", "signals"] as const; -type ListKey = (typeof LISTS)[number]; +export type ListKey = (typeof LISTS)[number]; let validators: Map | undefined; @@ -52,7 +52,7 @@ function validate(validator: ValidateFunction, data: unknown, label: string, iss } /** The files a list is read from, relative to the data folder, in load order. */ -async function listFiles(dataDir: string, name: ListKey): Promise { +export async function listFiles(dataDir: string, name: ListKey): Promise { const files: string[] = []; if (existsSync(path.join(dataDir, `${name}.yaml`))) files.push(`${name}.yaml`); const dir = path.join(dataDir, name); diff --git a/server/src/trace.ts b/server/src/trace.ts index b37b669..dd431dd 100644 --- a/server/src/trace.ts +++ b/server/src/trace.ts @@ -110,6 +110,14 @@ export function traceThing(house: House, index: HouseIndex, id: string): Trace | for (const cable of house.cables) { if (touches(cable.from)) cables.push({ cable, here: cable.from!, there: cable.to ? refFor(index, cable.to) : null }); else if (touches(cable.to)) cables.push({ cable, here: cable.to!, there: cable.from ? refFor(index, cable.from) : null }); + else { + // Only a core lands here, e.g. one core of a multi-core cable on a terminal. + const core = cable.cores?.find((k) => touches(k.from) || touches(k.to)); + if (!core) continue; + const atFrom = touches(core.from); + const there = atFrom ? cable.to : cable.from; + cables.push({ cable, here: (atFrom ? core.from : core.to)!, there: there ? refFor(index, there) : null }); + } } const signals = house.signals.filter((s) => (s.point != null && here.has(s.point)) || touches(s.port)); @@ -122,7 +130,7 @@ export function traceThing(house: House, index: HouseIndex, id: string): Trace | name: p.name, signal: p.signal, signals: house.signals.filter((s) => s.port === ref), - cables: cables.filter((c) => c.here === ref), + cables: cables.filter((c) => c.here === ref || c.cable.cores?.some((k) => k.from === ref || k.to === ref)), }; }) : []; diff --git a/server/test/edit.test.ts b/server/test/edit.test.ts new file mode 100644 index 0000000..9ce4fda --- /dev/null +++ b/server/test/edit.test.ts @@ -0,0 +1,115 @@ +import { execFileSync } from "node:child_process"; +import { cp, mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { buildApp } from "../src/app.js"; +import { circuitOf } from "../src/circuits.js"; +import { indexHouse } from "../src/index-house.js"; +import { loadHouse } from "../src/load.js"; +import { traceThing } from "../src/trace.js"; + +const DEMO = fileURLToPath(new URL("../../examples/demo-house/", import.meta.url)); + +async function demoApp(readOnly = false, gitCommit = false) { + const dir = await mkdtemp(path.join(tmpdir(), "hadoc-edit-")); + await cp(DEMO, dir, { recursive: true }); + return { dir, app: await buildApp({ dataDir: dir, readOnly, gitCommit }) }; +} + +describe("editing from the app", () => { + it("moves a point and keeps the comments in the file", async () => { + const { dir, app } = await demoApp(); + const res = await app.inject({ method: "PATCH", url: "/api/points/0-HAL-SW-01", payload: { pos: [55.4, 700.6] } }); + expect(res.statusCode).toBe(200); + const text = await readFile(path.join(dir, "points/0-ground-floor.yaml"), "utf8"); + expect(text).toContain("pos: [55, 701]"); + expect(text).toContain("# yaml-language-server"); + const house = await app.inject({ url: "/api/house" }).then((r) => r.json()); + expect(house.points.find((p: { id: string }) => p.id === "0-HAL-SW-01").pos).toEqual([55, 701]); + }); + + it("sets and removes a cable route per floor", async () => { + const { dir, app } = await demoApp(); + let res = await app.inject({ + method: "PUT", + url: "/api/cables/K-0031/route", + payload: { level: "0", path: [[20, 20], [150, 20], [150, 250]] }, + }); + expect(res.statusCode).toBe(200); + let text = await readFile(path.join(dir, "cables.yaml"), "utf8"); + expect(text).toMatch(/id: K-0031[\s\S]*route:\s+- level: "0"\s+path: \[\[20, 20\]/); + expect(text).toContain("# Cable IDs are the labels on the cables."); + + res = await app.inject({ method: "PUT", url: "/api/cables/K-0031/route", payload: { level: "0", path: null } }); + expect(res.statusCode).toBe(200); + text = await readFile(path.join(dir, "cables.yaml"), "utf8"); + expect(text).not.toMatch(/id: K-0031[^-]*route:/); + }); + + it("creates a point from an unlinked PLC signal", async () => { + const { dir, app } = await demoApp(); + const res = await app.inject({ + method: "POST", + url: "/api/points", + payload: { type: "SW", level: "0", room: "HAL", name: "Hall switch, right button", signal: "PLC-SM_0_A" }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().id).toBe("0-HAL-SW-02"); + expect(res.json().file).toBe("points/0-ground-floor.yaml"); + + const signals = await readFile(path.join(dir, "signals.yaml"), "utf8"); + expect(signals).toMatch(/- id: PLC-SM_0_A\n {2}point: 0-HAL-SW-02\n$/); + + const house = await loadHouse(dir); + expect(house.issues.filter((i) => i.severity === "error")).toEqual([]); + expect(house.signals.find((s) => s.id === "PLC-SM_0_A")).toMatchObject({ point: "0-HAL-SW-02", source: "wago" }); + }); + + it("commits each edit when the data folder is a git repository", async () => { + const { dir, app } = await demoApp(false, true); + const git = (...args: string[]) => execFileSync("git", ["-C", dir, ...args], { encoding: "utf8" }); + git("init", "-q"); + git("add", "."); + git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "init"); + const res = await app.inject({ method: "PATCH", url: "/api/points/0-KIT-SKT-01", payload: { pos: [690, 25] } }); + expect(res.json().committed).toBe(true); + expect(git("log", "-1", "--format=%an: %s").trim()).toBe("HAdoc: Move 0-KIT-SKT-01"); + expect(git("status", "--porcelain")).toBe(""); + }); + + it("rejects unknown rooms and refuses everything when read-only", async () => { + const { app } = await demoApp(); + const bad = await app.inject({ method: "POST", url: "/api/points", payload: { type: "SW", level: "0", room: "XXX" } }); + expect(bad.statusCode).toBe(400); + expect(bad.json().error).toContain("XXX"); + + const ro = await demoApp(true); + const res = await ro.app.inject({ method: "PATCH", url: "/api/points/0-HAL-SW-01", payload: { pos: [1, 1] } }); + expect(res.statusCode).toBe(404); + expect((await ro.app.inject({ url: "/api/house" })).json().editable).toBe(false); + }); +}); + +describe("circuits and cores", () => { + it("finds the circuit behind each point, also through a powered_by chain", async () => { + const house = await loadHouse(DEMO); + const map = circuitOf(house, indexHouse(house)); + expect(map["0-KIT-SKT-01"]).toBe("F1"); + expect(map["0-HAL-LGT-01"]).toBe("F2"); + // The ESP is powered by the Sonoff, which F3 feeds. + expect(map["esp-dressing"]).toBe("F3"); + expect(map["1-DRS-ESP-01"]).toBe("F3"); + }); + + it("shows a cable on every terminal one of its cores lands on", async () => { + const house = await loadHouse(DEMO); + const index = indexHouse(house); + const di = traceThing(house, index, "plc-di01")!; + expect(di.ports.find((p) => p.name === "DI 01.2")!.cables.map((c) => c.cable.id)).toEqual(["K-0012"]); + const x1 = traceThing(house, index, "X1")!; + expect(x1.cables.map((c) => c.cable.id)).toEqual(["K-0012"]); + expect(x1.ports.find((p) => p.name === "1")!.cables).toHaveLength(1); + }); +}); diff --git a/web/src/App.svelte b/web/src/App.svelte index 3716faf..fb73ec6 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1,6 +1,7 @@ + +{#if !open} + +{:else} +
+ + {#if target === NEW} + + + + + {/if} +
+ + +
+
+{/if} + + diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index cb8e33f..0e1e8c0 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,10 +1,34 @@ -import type { House, Issue } from "../../../server/src/model.ts"; +import type { House, Issue, XY } from "../../../server/src/model.ts"; import type { Trace } from "../../../server/src/trace.ts"; -export type { Cable, Device, Floor, House, Issue, Point, Settings } from "../../../server/src/model.ts"; +export type { Cable, Circuit, Device, Floor, House, Issue, Point, Settings, Signal, XY } from "../../../server/src/model.ts"; export type { Trace, ThingRef } from "../../../server/src/trace.ts"; -export type HouseData = Omit & { issueCount: { errors: number; warnings: number } }; +export type HouseData = Omit & { + issueCount: { errors: number; warnings: number }; + /** Point or device ID to the circuit that feeds it. */ + circuitOf: Record; + /** False when the server refuses edits. */ + editable: boolean; +}; + +export interface NewPoint { + type: string; + level: string; + room: string; + name?: string; + pos?: XY | null; + /** Signal to link to the new point. */ + signal?: string; +} + +/** What the details panel can ask the app to do in edit mode. */ +export interface EditActions { + place: (pointId: string) => void; + route: (cableId: string) => void; + link: (signalId: string, point: string | null) => void; + create: (point: NewPoint) => void; +} async function getJson(url: string): Promise { const res = await fetch(url); @@ -12,8 +36,23 @@ async function getJson(url: string): Promise { return res.json() as Promise; } +async function send(method: string, url: string, body: unknown): Promise { + const res = await fetch(url, { method, headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); + const data = (await res.json().catch(() => ({}))) as { error?: string; message?: string }; + if (!res.ok) throw new Error(data.error ?? data.message ?? `${res.status} ${res.statusText}`); + return data as T; +} + +type Saved = { id: string; file: string; committed: boolean }; + export const api = { house: () => getJson("/api/house"), issues: () => getJson("/api/issues"), trace: (id: string) => getJson(`/api/trace/${encodeURIComponent(id)}`), + + movePoint: (id: string, pos: XY | null) => send("PATCH", `/api/points/${encodeURIComponent(id)}`, { pos }), + createPoint: (point: NewPoint) => send("POST", "/api/points", point), + setRoute: (id: string, level: string, path: XY[] | null) => + send("PUT", `/api/cables/${encodeURIComponent(id)}/route`, { level, path }), + linkSignal: (id: string, point: string | null) => send("PATCH", `/api/signals/${encodeURIComponent(id)}`, { point }), }; diff --git a/web/src/lib/colours.ts b/web/src/lib/colours.ts new file mode 100644 index 0000000..bbe927d --- /dev/null +++ b/web/src/lib/colours.ts @@ -0,0 +1,31 @@ +// Colours for circuits on the plan and for cable cores. + +/** Distinct enough on both light and dark backgrounds. */ +const PALETTE = ["#e6194b", "#3cb44b", "#4363d8", "#f58231", "#911eb4", "#14a3a3", "#d6339a", "#8a9a1b", "#9a6324", "#5c6bc0"]; + +export function circuitColours(ids: string[]): Map { + return new Map(ids.map((id, i) => [id, PALETTE[i % PALETTE.length]!])); +} + +// Core colour names as electricians write them, in English, Dutch and French. +const CORE: Record = { + white: "#f5f5f5", wit: "#f5f5f5", blanc: "#f5f5f5", + black: "#111", zwart: "#111", noir: "#111", + brown: "#7b4a1e", bruin: "#7b4a1e", brun: "#7b4a1e", + blue: "#1d4ed8", blauw: "#1d4ed8", bleu: "#1d4ed8", + grey: "#8b8f94", gray: "#8b8f94", grijs: "#8b8f94", gris: "#8b8f94", + green: "#16a34a", groen: "#16a34a", vert: "#16a34a", + yellow: "#facc15", geel: "#facc15", jaune: "#facc15", + red: "#dc2626", rood: "#dc2626", rouge: "#dc2626", + orange: "#f97316", oranje: "#f97316", + pink: "#f472b6", roze: "#f472b6", rose: "#f472b6", + violet: "#7c3aed", purple: "#7c3aed", paars: "#7c3aed", +}; + +/** A CSS background for a core: one colour, or two stripes for "green/yellow". */ +export function coreBackground(name: string): string | undefined { + const parts = name.toLowerCase().split(/[\s/-]+/).map((p) => CORE[p]); + if (parts.length === 0 || parts.some((p) => !p)) return undefined; + if (parts.length === 1) return parts[0]; + return `repeating-linear-gradient(135deg, ${parts.map((c, i) => `${c} ${i * 4}px ${(i + 1) * 4}px`).join(", ")})`; +}