diff --git a/README.md b/README.md index 3004675..56edc6a 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,8 @@ Copy [`examples/demo-house`](examples/demo-house) into its own (private) git rep | `circuits.yaml` | Breaker circuits and what they feed | | `signals.yaml` | Where software sees a port: PLC name and address, MQTT topic, openHAB item | +Every list can also be split over a folder: `devices.yaml` and `devices/*.yaml` are read together, like `points/`. + Cable and circuit ends are written as a point or device ID, or as `device:port` (for example `plc-di01:DI 01.1`). Plan images go in `plans/`, photos in `photos/`. ### Point IDs @@ -68,6 +70,34 @@ types: Room codes are defined per floor in `site.yaml`. +### Importing the WAGO PLC configuration + +HAdoc reads the CODESYS 2.3 (WAGO-I/O-PRO) PLC configuration export directly, so the PLC side never has to be typed in: + +1. In CODESYS, open the PLC configuration and choose **Project > Export**. Save it in your data folder, e.g. `imports/plc-configuration.exp`. +2. Point `settings.yaml` at it: + + ```yaml + imports: + wago: + config: imports/plc-configuration.exp + controller: plc # device ID of the PLC, optional + id_prefix: plc- # optional, the default + ``` + +Each I/O module becomes a device named after its comment (`DI 01` becomes `plc-di01`), with one port per channel (`DI 01.1` to `DI 01.4`). Each channel with a symbolic name becomes a signal `PLC-` with its IEC address; a real channel comment becomes the signal's name. Channels without a name show as spare ports. + +The export is read on every load, so re-exporting after a change in CODESYS updates HAdoc. To add something to an imported record, write an entry with the same ID; its fields are laid over the import: + +```yaml +# signals.yaml +- id: PLC-SB_0_1L + name: Hall switch + point: 0-HAL-SW-01 +``` + +Imported signals that are not linked to a point yet are listed under "To check". + ### Validate ```bash @@ -90,7 +120,7 @@ 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. -2. **WAGO import** from CODESYS 2.3 `PLC CONFIGURATION.EXP`, seeding points from channel names and comments. +2. **WAGO import** from the CODESYS 2.3 PLC configuration export *(done)*. 3. **Cables and circuits**: drawing routes, terminals and cores, breaker colouring. 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. diff --git a/examples/demo-house/devices.yaml b/examples/demo-house/devices.yaml index 82f3f9e..a5a85bd 100644 --- a/examples/demo-house/devices.yaml +++ b/examples/demo-house/devices.yaml @@ -4,27 +4,7 @@ model: WAGO 750-881 location: plc-cabinet hostname: plc.lan - notes: Low-level logic for switches and blinds. - -- id: plc-di01 - kind: io-module - model: WAGO 750-403 (4 DI 24V) - label: DI 01 - location: plc - ports: - - { name: "DI 01.1", signal: DI 24V } - - { name: "DI 01.2", signal: DI 24V } - - { name: "DI 01.3", signal: DI 24V } - - { name: "DI 01.4", signal: DI 24V } - -- id: plc-do01 - kind: io-module - model: WAGO 750-504 (4 DO 24V) - label: DO 01 - location: plc - ports: - - { name: "DO 01.1", signal: DO 24V } - - { name: "DO 01.2", signal: DO 24V } + notes: Low-level logic for switches and blinds. Its I/O modules come from imports/plc-configuration.exp. - id: esp-dressing kind: esp diff --git a/examples/demo-house/imports/plc-configuration.exp b/examples/demo-house/imports/plc-configuration.exp new file mode 100644 index 0000000..241a5a5 --- /dev/null +++ b/examples/demo-house/imports/plc-configuration.exp @@ -0,0 +1,143 @@ +PLC_CONFIGURATION +_GLOBAL +_VERSION: 3 +_AUTOADR: 0 +_CHECKADR: 0 +_SAVECONFIGFILESINPROJECT: 0 +_END_GLOBAL + +_MODULE: '3S' +_SECTION_NAME: 'Root' +_INDEX_IN_PARENT: '-1' +_MODULE_NAME: 'Hardware configuration' +_NODE_ID: -1 +_IECIN: %IB0 +_IECOUT: %QB0 +_IECDIAG: %MB0 +_DOWNLOAD: 1 +_EXCLUDEFROMAUTOADR: 0 +_COMMENT: '' + +_MODULE: '3S' +_SECTION_NAME: 'K_Bus' +_INDEX_IN_PARENT: '1' +_MODULE_NAME: 'K-Bus' +_NODE_ID: 0 +_IECIN: %IB0 +_IECOUT: %QB0 +_IECDIAG: %MB0 +_DOWNLOAD: 1 +_EXCLUDEFROMAUTOADR: 0 +_COMMENT: 'Ethernet Controller 100MBit 2Port' + +_MODULE: '3S' +_SECTION_NAME: 'Type_1_4_Channels' +_INDEX_IN_PARENT: '1' +_MODULE_NAME: '0750-0403 4 DI 24 V DC 0.2ms' +_NODE_ID: 0 +_IECIN: %IB8 +_IECOUT: %QB8 +_IECDIAG: %MB0 +_DOWNLOAD: 1 +_EXCLUDEFROMAUTOADR: 0 +_COMMENT: 'DI 01' + +_PARAMETER +_PARAM 10000: 0, 'PLC' +_PARAM 10001: 0, 'plugged' +_END_PARAMETER + +_CHANNEL +_SECTION_NAME: 'BOOLOnX_I' +_INDEX_IN_PARENT: '1' +_SYMBOLIC_NAME: 'SB_0_1L' +_COMMENT: 'Ch_1 Digital input' +_CHANNEL_MODE: 'I' +_IECADR: %IX4.0 +_END_CHANNEL + +_CHANNEL +_SECTION_NAME: 'BOOLOnX_I' +_INDEX_IN_PARENT: '2' +_SYMBOLIC_NAME: 'SB_0_1R' +_COMMENT: 'Ch_2 Digital input' +_CHANNEL_MODE: 'I' +_IECADR: %IX4.1 +_END_CHANNEL + +_CHANNEL +_SECTION_NAME: 'BOOLOnX_I' +_INDEX_IN_PARENT: '3' +_SYMBOLIC_NAME: 'SM_0_A' +_COMMENT: 'Voordeur' +_CHANNEL_MODE: 'I' +_IECADR: %IX4.2 +_END_CHANNEL + +_CHANNEL +_SECTION_NAME: 'BOOLOnX_I' +_INDEX_IN_PARENT: '4' +_SYMBOLIC_NAME: '' +_COMMENT: 'Ch_4 Digital input' +_CHANNEL_MODE: 'I' +_IECADR: %IX4.3 +_END_CHANNEL +_END_MODULE + +_MODULE: '3S' +_SECTION_NAME: 'Type_10_4_Channels' +_INDEX_IN_PARENT: '2' +_MODULE_NAME: '0750-0504 4 DO 24V DC 0.5A' +_NODE_ID: 1 +_IECIN: %IB8 +_IECOUT: %QB8 +_IECDIAG: %MB0 +_DOWNLOAD: 1 +_EXCLUDEFROMAUTOADR: 0 +_COMMENT: 'DO 01' + +_PARAMETER +_PARAM 10000: 0, 'PLC' +_PARAM 10001: 0, 'plugged' +_END_PARAMETER + +_CHANNEL +_SECTION_NAME: 'BOOLOnX_Q' +_INDEX_IN_PARENT: '1' +_SYMBOLIC_NAME: 'R_0_HAL' +_COMMENT: 'Ch_1 Digital output' +_CHANNEL_MODE: 'Q' +_IECADR: %QX4.0 +_END_CHANNEL + +_CHANNEL +_SECTION_NAME: 'BOOLOnX_Q' +_INDEX_IN_PARENT: '2' +_SYMBOLIC_NAME: 'R_1_BED_UP' +_COMMENT: 'Rolluik slaapkamer op' +_CHANNEL_MODE: 'Q' +_IECADR: %QX4.1 +_END_CHANNEL + +_CHANNEL +_SECTION_NAME: 'BOOLOnX_Q' +_INDEX_IN_PARENT: '3' +_SYMBOLIC_NAME: 'R_1_BED_DN' +_COMMENT: 'Rolluik slaapkamer neer' +_CHANNEL_MODE: 'Q' +_IECADR: %QX4.2 +_END_CHANNEL + +_CHANNEL +_SECTION_NAME: 'BOOLOnX_Q' +_INDEX_IN_PARENT: '4' +_SYMBOLIC_NAME: '' +_COMMENT: 'Ch_4 Digital output' +_CHANNEL_MODE: 'Q' +_IECADR: %QX4.3 +_END_CHANNEL +_END_MODULE + +_END_MODULE +_END_MODULE +PLC_END diff --git a/examples/demo-house/settings.yaml b/examples/demo-house/settings.yaml index 38e16b4..36aaca6 100644 --- a/examples/demo-house/settings.yaml +++ b/examples/demo-house/settings.yaml @@ -20,3 +20,9 @@ types: - { code: BLD, name: Blind motor, layer: automation } - { code: ESP, name: Hidden ESP board, layer: hidden } - { code: JB, name: Junction box, layer: hidden } + +# Exports from other systems, read every time the data loads. +imports: + wago: + config: imports/plc-configuration.exp + controller: plc diff --git a/examples/demo-house/signals.yaml b/examples/demo-house/signals.yaml index 0e72338..ce5f47c 100644 --- a/examples/demo-house/signals.yaml +++ b/examples/demo-house/signals.yaml @@ -1,10 +1,18 @@ # yaml-language-server: $schema=../../schema/signals.schema.json -- id: SIG-0-HAL-SW-01 +# PLC signals are imported from imports/plc-configuration.exp. +# An entry with the same ID adds to the imported one, here to link it to a point. +- id: PLC-SB_0_1L name: Hall switch - port: "plc-di01:DI 01.1" point: 0-HAL-SW-01 - plc: { name: SB_0_1L, address: "%IX4.0" } - logic: plc + +- id: PLC-R_0_HAL + point: 0-HAL-LGT-01 + +- id: PLC-R_1_BED_UP + point: 1-BED-BLD-01 + +- id: PLC-R_1_BED_DN + point: 1-BED-BLD-01 - id: SIG-1-DRS-PIR-01 name: Dressing room motion diff --git a/schema/settings.schema.json b/schema/settings.schema.json index 397a40c..ca8ea5f 100644 --- a/schema/settings.schema.json +++ b/schema/settings.schema.json @@ -5,21 +5,38 @@ "description": "House-wide settings, including the configurable point ID scheme and abbreviations.", "type": "object", "additionalProperties": false, - "required": ["ids", "levels", "types"], + "required": [ + "ids", + "levels", + "types" + ], "properties": { - "name": { "type": "string", "description": "Name of the house, shown in the app header." }, - "language": { "type": "string", "description": "UI language hint, e.g. 'nl' or 'en'." }, + "name": { + "type": "string", + "description": "Name of the house, shown in the app header." + }, + "language": { + "type": "string", + "description": "UI language hint, e.g. 'nl' or 'en'." + }, "ids": { "type": "object", "additionalProperties": false, - "required": ["pattern"], + "required": [ + "pattern" + ], "properties": { "pattern": { "type": "string", "description": "Point ID pattern. Placeholders: {level}, {room}, {type}, {nr}.", "default": "{level}-{room}-{type}-{nr}" }, - "nr_digits": { "type": "integer", "minimum": 1, "maximum": 6, "default": 2 } + "nr_digits": { + "type": "integer", + "minimum": 1, + "maximum": 6, + "default": 2 + } } }, "levels": { @@ -28,10 +45,18 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["code", "name"], + "required": [ + "code", + "name" + ], "properties": { - "code": { "type": "string", "pattern": "^[A-Za-z0-9]+$" }, - "name": { "type": "string" } + "code": { + "type": "string", + "pattern": "^[A-Za-z0-9]+$" + }, + "name": { + "type": "string" + } } } }, @@ -41,12 +66,61 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["code", "name"], + "required": [ + "code", + "name" + ], "properties": { - "code": { "type": "string", "pattern": "^[A-Za-z0-9]+$" }, - "name": { "type": "string" }, - "symbol": { "type": "string", "description": "Short text shown on the plan symbol; defaults to the code." }, - "layer": { "enum": ["power", "data", "automation", "hidden"], "default": "power" } + "code": { + "type": "string", + "pattern": "^[A-Za-z0-9]+$" + }, + "name": { + "type": "string" + }, + "symbol": { + "type": "string", + "description": "Short text shown on the plan symbol; defaults to the code." + }, + "layer": { + "enum": [ + "power", + "data", + "automation", + "hidden" + ], + "default": "power" + } + } + } + }, + "imports": { + "type": "object", + "additionalProperties": false, + "description": "Files exported from other systems, read on every load.", + "properties": { + "wago": { + "type": "object", + "additionalProperties": false, + "required": [ + "config" + ], + "description": "CODESYS 2.3 PLC configuration export (Project > Export, PLC configuration).", + "properties": { + "config": { + "type": "string", + "description": "Path relative to the data folder, e.g. imports/PLC CONFIGURATION.EXP." + }, + "controller": { + "type": "string", + "description": "Device ID of the PLC; modules get it as their location." + }, + "id_prefix": { + "type": "string", + "default": "plc-", + "description": "Prefix for module device IDs." + } + } } } } diff --git a/schema/signals.schema.json b/schema/signals.schema.json index d7505af..cce0ec6 100644 --- a/schema/signals.schema.json +++ b/schema/signals.schema.json @@ -7,32 +7,92 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["id", "port"], + "required": [ + "id" + ], "properties": { - "id": { "type": "string" }, - "name": { "type": "string" }, - "port": { "type": ["string", "null"], "description": "'device:port' this signal reads or drives." }, - "point": { "type": ["string", "null"], "description": "Point this signal belongs to." }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "port": { + "type": [ + "string", + "null" + ], + "description": "'device:port' this signal reads or drives. Required unless the entry adds to an imported signal with the same ID." + }, + "point": { + "type": [ + "string", + "null" + ], + "description": "Point this signal belongs to." + }, "plc": { "type": "object", "additionalProperties": false, "properties": { - "name": { "type": ["string", "null"], "description": "Software name, e.g. SB_0_8L." }, - "address": { "type": ["string", "null"], "description": "IEC address, e.g. %IX4.2." }, - "modbus": { "type": ["string", "null"] } + "name": { + "type": [ + "string", + "null" + ], + "description": "Software name, e.g. SB_0_8L." + }, + "address": { + "type": [ + "string", + "null" + ], + "description": "IEC address, e.g. %IX4.2." + }, + "modbus": { + "type": [ + "string", + "null" + ] + } } }, - "mqtt": { "type": ["string", "null"] }, + "mqtt": { + "type": [ + "string", + "null" + ] + }, "openhab": { "type": "object", "additionalProperties": false, "properties": { - "thing": { "type": ["string", "null"] }, - "item": { "type": ["string", "null"] } + "thing": { + "type": [ + "string", + "null" + ] + }, + "item": { + "type": [ + "string", + "null" + ] + } } }, - "logic": { "enum": ["plc", "openhab", "both", "none", null] }, - "notes": { "type": "string" } + "logic": { + "enum": [ + "plc", + "openhab", + "both", + "none", + null + ] + }, + "notes": { + "type": "string" + } } } } diff --git a/server/src/checks.ts b/server/src/checks.ts index 2efd78e..0d3b1d6 100644 --- a/server/src/checks.ts +++ b/server/src/checks.ts @@ -129,6 +129,7 @@ export function checkHouse(house: House): Issue[] { for (const s of house.signals) { mustResolve("signals.yaml", s.id, "port", s.port); if (s.port == null) warn("signals.yaml", s.id, "Port is unknown"); + if (s.point == null && s.source) warn(`import: ${s.source}`, s.id, "Not linked to a point yet"); if (s.point != null) { const t = index.things.get(s.point); if (!t || t.kind !== "point") error("signals.yaml", s.id, `point: "${s.point}" is not a point`); diff --git a/server/src/importers/index.ts b/server/src/importers/index.ts new file mode 100644 index 0000000..0bd1c09 --- /dev/null +++ b/server/src/importers/index.ts @@ -0,0 +1,30 @@ +import { readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import type { Device, Issue, Settings, Signal } from "../model.js"; +import { parseWagoConfig, wagoToHadoc } from "./wago.js"; + +/** Runs the file imports configured in settings.imports. */ +export async function runImports(dataDir: string, settings: Settings, issues: Issue[]) { + const devices: Device[] = []; + const signals: Signal[] = []; + + const wago = settings.imports?.wago; + if (wago) { + const file = path.join(dataDir, wago.config); + if (!existsSync(file)) { + issues.push({ severity: "error", file: "settings.yaml", message: `imports.wago.config: ${wago.config} does not exist` }); + } else { + // CODESYS 2.3 writes Windows-1252; latin1 decodes it without loss for the characters it uses. + const modules = parseWagoConfig(await readFile(file, "latin1")); + if (modules.length === 0) { + issues.push({ severity: "error", file: wago.config, message: "No I/O modules found; is this a PLC configuration export?" }); + } + const result = wagoToHadoc(modules, { controller: wago.controller, idPrefix: wago.id_prefix }); + devices.push(...result.devices); + signals.push(...result.signals); + } + } + + return { devices, signals }; +} diff --git a/server/src/importers/wago.ts b/server/src/importers/wago.ts new file mode 100644 index 0000000..c9dcd71 --- /dev/null +++ b/server/src/importers/wago.ts @@ -0,0 +1,129 @@ +// Parser for the CODESYS 2.3 PLC configuration export ("PLC CONFIGURATION.EXP"), +// as written by WAGO-I/O-PRO / CODESYS 2.3 via Project > Export. + +import type { Device, Signal } from "../model.js"; + +export interface WagoChannel { + index: number; + symbolicName: string; + comment: string; + mode: "I" | "Q"; + address: string; + section: string; +} + +export interface WagoModule { + /** Order code as exported, e.g. "0750-0403 4 DI 24 V DC 0.2ms". */ + name: string; + /** The comment, which by convention holds the physical label, e.g. "DI 01". */ + comment: string; + channels: WagoChannel[]; +} + +export interface WagoImportOptions { + /** Device ID of the controller the modules plug into. */ + controller?: string; + /** Prefix for module device IDs; "plc-" gives "plc-di01". */ + idPrefix?: string; +} + +const DEFAULT_COMMENT = /^(Ch_\d+ Digital (input|output)|First Byte of \d+)?$/i; + +function unquote(value: string): string { + const v = value.trim(); + return v.startsWith("'") && v.endsWith("'") ? v.slice(1, -1).replace(/''/g, "'") : v; +} + +/** Reads every module that has channels. Fieldbus and flag sections are skipped. */ +export function parseWagoConfig(text: string): WagoModule[] { + const modules: WagoModule[] = []; + const stack: { fields: Record; channels: WagoChannel[] }[] = []; + let channel: Record | null = null; + + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + if (line.startsWith("_MODULE:")) { + stack.push({ fields: {}, channels: [] }); + } else if (line === "_END_MODULE") { + const mod = stack.pop(); + if (mod && mod.channels.length > 0) { + modules.push({ name: mod.fields._MODULE_NAME ?? "", comment: mod.fields._COMMENT ?? "", channels: mod.channels }); + } + } else if (line === "_CHANNEL") { + channel = {}; + } else if (line === "_END_CHANNEL") { + const mod = stack.at(-1); + if (channel && mod) { + mod.channels.push({ + index: Number(channel._INDEX_IN_PARENT ?? mod.channels.length + 1), + symbolicName: channel._SYMBOLIC_NAME ?? "", + comment: channel._COMMENT ?? "", + mode: channel._CHANNEL_MODE === "Q" ? "Q" : "I", + address: channel._IECADR ?? "", + section: channel._SECTION_NAME ?? "", + }); + } + channel = null; + } else { + const m = /^(_[A-Z_]+):\s*(.*)$/.exec(line); + if (!m) continue; + const target = channel ?? stack.at(-1)?.fields; + if (target && !(m[1]! in target)) target[m[1]!] = unquote(m[2]!); + } + } + // I/O modules close before the next one opens, so this is already K-bus order. + return modules; +} + +function modelName(orderCode: string): string { + // "0750-0403 4 DI 24 V DC 0.2ms" -> "WAGO 750-403 4 DI 24 V DC 0.2ms" + return `WAGO ${orderCode.replace(/^0*(\d+)-0*(\d+)/, "$1-$2")}`.replace(/#\d+/, ""); +} + +function signalType(module: WagoModule, ch: WagoChannel): string { + const volts = /(\d+)\s*V\s*DC/i.exec(module.name)?.[1]; + if (ch.section.startsWith("BYTE")) return ch.mode === "I" ? "byte in" : "byte out"; + return `${ch.mode === "I" ? "DI" : "DO"}${volts ? ` ${volts}V` : ""}`; +} + +/** Turns the export into HAdoc devices (one per module, with ports) and signals (one per named channel). */ +export function wagoToHadoc(modules: WagoModule[], options: WagoImportOptions = {}) { + const prefix = options.idPrefix ?? "plc-"; + const devices: Device[] = []; + const signals: Signal[] = []; + const usedIds = new Set(); + + modules.forEach((module, i) => { + const label = module.comment.trim() || `M${String(i + 1).padStart(2, "0")}`; + let id = `${prefix}${label.toLowerCase().replace(/[^a-z0-9]+/g, "")}`; + while (usedIds.has(id)) id += "x"; + usedIds.add(id); + + const ports = module.channels.map((ch) => ({ name: `${label}.${ch.index}`, signal: signalType(module, ch) })); + devices.push({ + id, + kind: "io-module", + model: modelName(module.name), + label, + ...(options.controller ? { location: options.controller } : {}), + ports, + source: "wago", + }); + + for (const ch of module.channels) { + if (!ch.symbolicName) continue; + const custom = DEFAULT_COMMENT.test(ch.comment.trim()) ? undefined : ch.comment.trim(); + signals.push({ + id: `PLC-${ch.symbolicName}`, + ...(custom ? { name: custom } : {}), + port: `${id}:${label}.${ch.index}`, + point: null, + plc: { name: ch.symbolicName, address: ch.address }, + logic: "plc", + source: "wago", + }); + } + }); + + return { devices, signals }; +} diff --git a/server/src/load.ts b/server/src/load.ts index a5b927a..4214e6d 100644 --- a/server/src/load.ts +++ b/server/src/load.ts @@ -5,22 +5,20 @@ import { fileURLToPath } from "node:url"; import { Ajv2020, type ValidateFunction } from "ajv/dist/2020.js"; import { parse as parseYaml } from "yaml"; import { checkHouse } from "./checks.js"; -import type { House, Issue } from "./model.js"; +import { runImports } from "./importers/index.js"; +import type { Device, House, Issue } from "./model.js"; const DEFAULT_SCHEMA_DIR = fileURLToPath(new URL("../../schema/", import.meta.url)); -const FILES = { - settings: "settings.yaml", - site: "site.yaml", - enclosures: "enclosures.yaml", - devices: "devices.yaml", - cables: "cables.yaml", - circuits: "circuits.yaml", - signals: "signals.yaml", -} as const; +/** Single-object files. */ +const OBJECTS = ["settings", "site"] as const; +/** + * List files. Each is read from `.yaml` and from every `/*.yaml`, + * so a big house can split them up (points per floor, devices per cabinet). + */ +const LISTS = ["points", "enclosures", "devices", "cables", "circuits", "signals"] as const; -type FileKey = keyof typeof FILES; -const REQUIRED: FileKey[] = ["settings", "site"]; +type ListKey = (typeof LISTS)[number]; let validators: Map | undefined; @@ -28,7 +26,7 @@ async function loadValidators(schemaDir: string): Promise(); - for (const name of [...Object.keys(FILES), "points"]) { + for (const name of [...OBJECTS, ...LISTS]) { const schema = JSON.parse(await readFile(path.join(schemaDir, `${name}.schema.json`), "utf8")); map.set(name, ajv.compile(schema)); } @@ -45,23 +43,49 @@ async function readYaml(file: string, issues: Issue[], label: string): Promise { + const files: string[] = []; + if (existsSync(path.join(dataDir, `${name}.yaml`))) files.push(`${name}.yaml`); + const dir = path.join(dataDir, name); + if (existsSync(dir)) { + for (const f of (await readdir(dir)).filter((f) => /\.ya?ml$/.test(f)).sort()) files.push(`${name}/${f}`); + } + return files; +} + +/** + * Hand-written records win over imported ones with the same ID: their fields are + * laid over the imported record, so you can add a location or a note to an + * imported PLC module without copying it. + */ +function overlay(imported: T[], written: T[], merge = (base: T, over: T): T => ({ ...base, ...over })): T[] { + const byId = new Map(imported.map((item) => [item.id, item])); + const result = [...imported]; + for (const item of written) { + const base = byId.get(item.id); + if (base) result[result.indexOf(base)] = merge(base, item); + else result.push(item); + } + return result; +} + +/** Ports are matched by name, so a hand-written device can annotate one port without dropping the others. */ +function mergeDevice(base: Device, over: Device): Device { + if (!over.ports || !base.ports) return { ...base, ...over }; + const ports = base.ports.map((p) => ({ ...p, ...over.ports!.find((o) => o.name === p.name) })); + for (const p of over.ports) if (!ports.some((q) => q.name === p.name)) ports.push(p); + return { ...base, ...over, ports }; +} + /** * Loads a HAdoc data folder. Never throws on bad data: every problem lands in * `house.issues`, so the app can still show what is valid. @@ -69,41 +93,44 @@ function validate( export async function loadHouse(dataDir: string, schemaDir = DEFAULT_SCHEMA_DIR): Promise { const v = await loadValidators(schemaDir); const issues: Issue[] = []; - const raw: Partial> = {}; - for (const [key, file] of Object.entries(FILES) as [FileKey, string][]) { + const objects: Partial> = {}; + for (const key of OBJECTS) { + const file = `${key}.yaml`; const full = path.join(dataDir, file); if (!existsSync(full)) { - if (REQUIRED.includes(key)) issues.push({ severity: "error", file, message: "File is missing" }); + issues.push({ severity: "error", file, message: "File is missing" }); continue; } const data = await readYaml(full, issues, file); - if (data !== undefined && validate(v.get(key)!, data, file, issues)) raw[key] = data; + if (data !== undefined && validate(v.get(key)!, data, file, issues)) objects[key] = data; } - const points: House["points"] = []; - const pointsDir = path.join(dataDir, "points"); - if (existsSync(pointsDir)) { - const files = (await readdir(pointsDir)).filter((f) => /\.ya?ml$/.test(f)).sort(); - for (const f of files) { - const label = `points/${f}`; - const data = await readYaml(path.join(pointsDir, f), issues, label); - if (data === null) continue; - if (data !== undefined && validate(v.get("points")!, data, label, issues)) { - points.push(...(data as House["points"])); - } + const lists = Object.fromEntries(LISTS.map((k) => [k, [] as unknown[]])) as Record; + for (const key of LISTS) { + for (const file of await listFiles(dataDir, key)) { + const data = await readYaml(path.join(dataDir, file), issues, file); + if (data === null || data === undefined) continue; + if (validate(v.get(key)!, data, file, issues)) lists[key].push(...(data as unknown[])); } } + const settings = (objects.settings as House["settings"]) ?? { + ids: { pattern: "{level}-{room}-{type}-{nr}" }, + levels: [], + types: [], + }; + const imported = await runImports(dataDir, settings, issues); + const house: House = { - settings: (raw.settings as House["settings"]) ?? { ids: { pattern: "{level}-{room}-{type}-{nr}" }, levels: [], types: [] }, - site: (raw.site as House["site"]) ?? { floors: [] }, - points, - enclosures: (raw.enclosures as House["enclosures"]) ?? [], - devices: (raw.devices as House["devices"]) ?? [], - cables: (raw.cables as House["cables"]) ?? [], - circuits: (raw.circuits as House["circuits"]) ?? [], - signals: (raw.signals as House["signals"]) ?? [], + settings, + site: (objects.site as House["site"]) ?? { floors: [] }, + points: lists.points as House["points"], + enclosures: lists.enclosures as House["enclosures"], + devices: overlay(imported.devices, lists.devices as House["devices"], mergeDevice), + cables: lists.cables as House["cables"], + circuits: lists.circuits as House["circuits"], + signals: overlay(imported.signals, lists.signals as House["signals"]), issues, }; house.issues.push(...checkHouse(house)); diff --git a/server/src/model.ts b/server/src/model.ts index c8d1555..50496c6 100644 --- a/server/src/model.ts +++ b/server/src/model.ts @@ -9,6 +9,9 @@ export interface Settings { ids: { pattern: string; nr_digits?: number }; levels: { code: string; name: string }[]; types: { code: string; name: string; symbol?: string; layer?: Layer }[]; + imports?: { + wago?: { config: string; controller?: string; id_prefix?: string }; + }; } export type Layer = "power" | "data" | "automation" | "hidden"; @@ -73,6 +76,8 @@ export interface Device { powered_by?: string | null; ports?: Port[]; notes?: string; + /** Set by the loader on records that come from an import, e.g. "wago". */ + source?: string; } export interface Cable { @@ -106,6 +111,8 @@ export interface Signal { openhab?: { thing?: string | null; item?: string | null }; logic?: "plc" | "openhab" | "both" | "none" | null; notes?: string; + /** Set by the loader on records that come from an import, e.g. "wago". */ + source?: string; } export interface Issue { diff --git a/server/src/trace.ts b/server/src/trace.ts index 9c43282..b37b669 100644 --- a/server/src/trace.ts +++ b/server/src/trace.ts @@ -16,8 +16,19 @@ export interface CableEnd { there: ThingRef | null; } +export interface PortUse { + name: string; + signal?: string; + /** Signals wired to this port, e.g. the PLC variable reading it. */ + signals: Signal[]; + /** Cables landing on this port, with their other end. */ + cables: CableEnd[]; +} + export interface Trace { subject: ThingRef; + /** For a device: every port and what uses it. */ + ports: PortUse[]; location: ThingRef | null; /** Devices that sit at this point, e.g. a hidden ESP. */ contains: ThingRef[]; @@ -103,8 +114,22 @@ export function traceThing(house: House, index: HouseIndex, id: string): Trace | const signals = house.signals.filter((s) => (s.point != null && here.has(s.point)) || touches(s.port)); + const ports: PortUse[] = + subject.kind === "device" + ? (subject.item.ports ?? []).map((p) => { + const ref = `${id}:${p.name}`; + return { + name: p.name, + signal: p.signal, + signals: house.signals.filter((s) => s.port === ref), + cables: cables.filter((c) => c.here === ref), + }; + }) + : []; + return { subject: { kind: subject.kind, id, label: label(subject) }, + ports, location, contains, poweredBy, diff --git a/server/test/wago.test.ts b/server/test/wago.test.ts new file mode 100644 index 0000000..6b5c176 --- /dev/null +++ b/server/test/wago.test.ts @@ -0,0 +1,78 @@ +import { cp, mkdtemp, readFile, writeFile } 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 { parseWagoConfig, wagoToHadoc } from "../src/importers/wago.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)); +const EXPORT = new URL("../../examples/demo-house/imports/plc-configuration.exp", import.meta.url); + +describe("WAGO PLC configuration import", () => { + it("reads every I/O module with its channels", async () => { + const modules = parseWagoConfig(await readFile(EXPORT, "latin1")); + expect(modules.map((m) => [m.comment, m.channels.length])).toEqual([ + ["DI 01", 4], + ["DO 01", 4], + ]); + expect(modules[0]!.channels[0]).toMatchObject({ symbolicName: "SB_0_1L", address: "%IX4.0", mode: "I" }); + }); + + it("makes a device per module and a signal per named channel", async () => { + const { devices, signals } = wagoToHadoc(parseWagoConfig(await readFile(EXPORT, "latin1")), { controller: "plc" }); + + expect(devices[0]).toMatchObject({ + id: "plc-di01", + model: "WAGO 750-403 4 DI 24 V DC 0.2ms", + label: "DI 01", + location: "plc", + ports: [ + { name: "DI 01.1", signal: "DI 24V" }, + { name: "DI 01.2", signal: "DI 24V" }, + { name: "DI 01.3", signal: "DI 24V" }, + { name: "DI 01.4", signal: "DI 24V" }, + ], + }); + // The fourth channel of each module has no name, so it is a spare port without a signal. + expect(signals.map((s) => s.id)).toEqual([ + "PLC-SB_0_1L", + "PLC-SB_0_1R", + "PLC-SM_0_A", + "PLC-R_0_HAL", + "PLC-R_1_BED_UP", + "PLC-R_1_BED_DN", + ]); + // Default CODESYS comments are dropped; real ones become the signal name. + expect(signals[0]!.name).toBeUndefined(); + expect(signals[2]).toMatchObject({ name: "Voordeur", port: "plc-di01:DI 01.3", plc: { address: "%IX4.2" } }); + }); + + it("lets hand-written entries add to imported ones", async () => { + const house = await loadHouse(DEMO); + const hall = house.signals.find((s) => s.id === "PLC-SB_0_1L"); + expect(hall).toMatchObject({ name: "Hall switch", point: "0-HAL-SW-01", port: "plc-di01:DI 01.1", source: "wago" }); + + const trace = traceThing(house, indexHouse(house), "plc-do01")!; + expect(trace.ports.map((p) => [p.name, p.signals.map((s) => s.point)])).toEqual([ + ["DO 01.1", ["0-HAL-LGT-01"]], + ["DO 01.2", ["1-BED-BLD-01"]], + ["DO 01.3", ["1-BED-BLD-01"]], + ["DO 01.4", []], + ]); + }); + + it("reports a missing export file", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "hadoc-")); + await cp(DEMO, dir, { recursive: true }); + const settings = path.join(dir, "settings.yaml"); + await writeFile(settings, (await readFile(settings, "utf8")).replace("imports/plc-configuration.exp", "imports/gone.exp")); + + const house = await loadHouse(dir); + expect(house.issues).toContainEqual( + expect.objectContaining({ severity: "error", message: "imports.wago.config: imports/gone.exp does not exist" }), + ); + }); +}); diff --git a/web/src/App.svelte b/web/src/App.svelte index 32110ed..3716faf 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -55,8 +55,12 @@ .filter((d) => hit(d.id, d.model, d.label, d.hostname)) .map((d) => ({ id: d.id, label: d.label ?? d.model ?? d.id })), ...house.signals - .filter((s) => hit(s.id, s.name, s.plc?.name, s.openhab?.item, s.mqtt) && s.point) - .map((s) => ({ id: s.point!, label: `${s.name ?? s.id} (${s.openhab?.item ?? s.plc?.name ?? s.mqtt ?? ""})` })), + .filter((s) => hit(s.id, s.name, s.plc?.name, s.openhab?.item, s.mqtt) && (s.point || s.port)) + .map((s) => ({ + // A signal opens its point, or else the device its port is on. + id: s.point ?? s.port!.split(":")[0]!, + label: `${s.name ?? s.id} (${s.openhab?.item ?? s.plc?.name ?? s.mqtt ?? ""})`, + })), ].slice(0, 12); }); @@ -70,7 +74,20 @@ } } - async function select(id: string | null) { + /** Signals, cables and circuits have no page of their own; open the thing they belong to. */ + function selectable(id: string): string { + if (!house) return id; + const signal = house.signals.find((s) => s.id === id); + if (signal) return signal.point ?? signal.port?.split(":")[0] ?? id; + const cable = house.cables.find((c) => c.id === id); + if (cable) return (cable.from ?? cable.to ?? id).split(":")[0]!; + const circuit = house.circuits.find((c) => c.id === id); + if (circuit?.breaker) return circuit.breaker; + return id; + } + + async function select(target: string | null) { + const id = target === null ? null : selectable(target); selected = id; query = ""; showIssues = false; diff --git a/web/src/lib/Details.svelte b/web/src/lib/Details.svelte index 0a1f6d1..fd3fdd2 100644 --- a/web/src/lib/Details.svelte +++ b/web/src/lib/Details.svelte @@ -21,6 +21,8 @@ ); const levelName = $derived(point ? house.settings.levels.find((l) => l.code === point.level)?.name : undefined); + const pointName = (id: string) => house.points.find((p) => p.id === id)?.name ?? id; + function canOpen(ref: ThingRef): boolean { return (ref.kind === "point" || ref.kind === "device" || ref.kind === "enclosure"); } @@ -77,6 +79,34 @@ {#if device.notes}

{device.notes}

{/if} {/if} + {#if trace.ports.length} +
+

Ports

+ + + {#each trace.ports as port (port.name)} + + + + + {/each} + +
{port.name} + {#each port.signals as s (s.id)} +
+ {#if s.plc?.name}{s.plc.name}{/if} + {#if s.name}{s.name}{/if} + {#if s.point}→ {/if} +
+ {/each} + {#each port.cables as end (end.cable.id)} +
{end.cable.id} to {@render link(end.there)}
+ {/each} + {#if !port.signals.length && !port.cables.length}spare{/if} +
+
+ {/if} + {#if trace.location}

Located in

@@ -125,7 +155,7 @@ {/if}
- {#if trace.signals.length} + {#if trace.signals.length && !trace.ports.length}

Automation

    @@ -134,7 +164,7 @@ {s.name ?? s.id} {#if s.logic}logic in {s.logic}{/if}
    - {#if s.plc?.name}
    PLC
    {s.plc.name}{#if s.plc.address} at {s.plc.address}{/if}
    {/if} + {#if s.plc?.name}
    PLC
    {[s.plc.name, s.plc.address].filter(Boolean).join(" at ")}
    {/if} {#if s.mqtt}
    MQTT
    {s.mqtt}
    {/if} {#if s.openhab?.item}
    openHAB
    {s.openhab.item}
    {/if}
    @@ -248,6 +278,25 @@ .callout p { margin: 0; } + .ports { + width: 100%; + border-collapse: collapse; + font-size: 14px; + } + .ports td { + border-top: 1px solid var(--border); + padding: 4px 6px 4px 0; + vertical-align: top; + } + .ports td:first-child { + white-space: nowrap; + } + .ports .mono { + margin-right: 6px; + } + .spare { + color: var(--muted); + } .tag { font-size: 12px; border: 1px solid var(--border); diff --git a/web/src/lib/Issues.svelte b/web/src/lib/Issues.svelte index 3f0bd3d..ec7ce7b 100644 --- a/web/src/lib/Issues.svelte +++ b/web/src/lib/Issues.svelte @@ -9,9 +9,17 @@ let { issues, onselect, onclose }: Props = $props(); - const sorted = $derived( - [...issues].sort((a, b) => (a.severity === b.severity ? a.file.localeCompare(b.file) : a.severity === "error" ? -1 : 1)), - ); + // Errors first, then one group per file or import, largest first. + const groups = $derived.by(() => { + const map = new Map(); + for (const issue of issues) { + const key = `${issue.severity}\u0000${issue.file}`; + map.set(key, [...(map.get(key) ?? []), issue]); + } + return [...map.values()].sort( + (a, b) => (a[0]!.severity === b[0]!.severity ? b.length - a.length : a[0]!.severity === "error" ? -1 : 1), + ); + }); @@ -55,17 +67,24 @@ font-size: 18px; cursor: pointer; } + details { + border-left: 3px solid var(--warning); + padding: 4px 10px; + margin: 12px 0; + } + details.error { + border-color: var(--error); + } + summary { + cursor: pointer; + } ul { list-style: none; padding: 0; + margin: 4px 0 0; } li { - border-left: 3px solid var(--warning); - padding: 4px 10px; - margin: 8px 0; - } - li.error { - border-color: var(--error); + margin: 4px 0; } .file { color: var(--muted); @@ -81,7 +100,4 @@ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; } - p { - margin: 2px 0 0; - }