Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
31 changes: 27 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 <hadoc@localhost>` 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 |
Expand All @@ -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).
Expand Down
7 changes: 7 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
6 changes: 6 additions & 0 deletions examples/demo-house/cables.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down
24 changes: 24 additions & 0 deletions examples/demo-house/circuits.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,32 @@
# 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
rating: C16
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]
26 changes: 26 additions & 0 deletions examples/demo-house/devices.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion schema/devices.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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." },
Expand Down
77 changes: 77 additions & 0 deletions server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions server/src/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
}
Expand Down
34 changes: 34 additions & 0 deletions server/src/circuits.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
const fed = new Map<string, string>();
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>()): 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<string, string> = {};
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;
}
Loading
Loading