From fe5cb4e2ec54096e7ecdd320fb4c1472ed7ca1d4 Mon Sep 17 00:00:00 2001 From: Claas Date: Sun, 30 Aug 2026 23:43:55 +0200 Subject: [PATCH] Build the interface: watch the bus, or talk to a device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two jobs share one port and are mutually exclusive, so they are two tabs rather than two panes. Watching is passive and safe to leave running while the fan controller drives the bus; talking sends requests and must not be used at the same time. Both say so in the panel itself rather than only in a README. `connection.ts` serves both from a single read loop, because a port has exactly one reader. Every chunk goes to the subscribers, which is how the monitor sees traffic, and while a request is outstanding it also goes to whatever is waiting for the reply. That reply is found by checksum rather than by counting bytes, which is also what lets it step over the echo of the request: some RS-485 adapters put what they transmit back on the receive line — `debug-listener` carries a flag for exactly that — and an echo is a perfectly valid frame with the right address and function code, so nothing but recognising the bytes rules it out. Tested. `client.ts` batches reads. Modbus reads a range, so the runs worked out in the layer below turn a device's whole register list into a handful of requests. A range the device refuses is recorded and the rest are still attempted, because one unreadable register should not cost the reading of every other one. Two things came from the `modern-web-guidance` skill and are worth not undoing: - Register write fields use `:user-invalid` rather than `:invalid`, so a field is not flagged red while it is still being typed — only once it has been left. The bounds are real min/max/step attributes taken from the register definition, which makes the browser's own validation the source of truth; the Write button is gated on `validity.valid` rather than on a second copy of the bounds. - The monitor's rows carry `content-visibility: auto` with `contain-intrinsic-size`, so a few hundred kept frames cost only what is on screen, without the scrollbar jumping. Verified against a fake port replaying the manuals' own frames: request and response are identified and paired, an unpaired read response is labelled with the range its request asked for, deliberately corrupt bytes appear as an "unaccounted" row, and the monitor resynchronises straight back into the conversation afterwards. Three defects that only showed up there are fixed: "1 holding registers", the RadiCal's D-prefix being applied to the relay's 0x03E8 by address magnitude, and port-setting labels wrapping away from the controls they name. Solid 2.0 notes for the next reader: there is no `onMount` — a component body already runs once during setup — and `createEffect` takes a compute and an effect function rather than one closure. Co-Authored-By: Claude Opus 5 --- serial/src/App.tsx | 85 ++++++- serial/src/app.css | 293 ++++++++++++++++++++++- serial/src/serial/client.ts | 117 +++++++++ serial/src/serial/connection.test.ts | 98 ++++++++ serial/src/serial/connection.ts | 239 +++++++++++++++++++ serial/src/ui/Devices.tsx | 341 +++++++++++++++++++++++++++ serial/src/ui/Monitor.tsx | 136 +++++++++++ serial/src/ui/PortPanel.tsx | 132 +++++++++++ serial/src/ui/format.ts | 79 +++++++ 9 files changed, 1509 insertions(+), 11 deletions(-) create mode 100644 serial/src/serial/client.ts create mode 100644 serial/src/serial/connection.test.ts create mode 100644 serial/src/serial/connection.ts create mode 100644 serial/src/ui/Devices.tsx create mode 100644 serial/src/ui/Monitor.tsx create mode 100644 serial/src/ui/PortPanel.tsx create mode 100644 serial/src/ui/format.ts diff --git a/serial/src/App.tsx b/serial/src/App.tsx index d9a0d2d..28f1d4e 100644 --- a/serial/src/App.tsx +++ b/serial/src/App.tsx @@ -1,4 +1,5 @@ -import { Show } from "solid-js"; +import { For, Show, createSignal, onCleanup } from "solid-js"; +import PortPanel from "./ui/PortPanel"; /** * Whether this browser can talk to a serial port at all. @@ -9,18 +10,96 @@ import { Show } from "solid-js"; */ const isSupported = "serial" in navigator; +type Entry = { port: SerialPort; info: Partial }; + export default function App() { return (
-

Modbus Serial Tool

+
+

Modbus Serial Tool

+

+ Watches the RS-485 bus and reads or writes the registers of the devices on it. Register + names and codings come from the manufacturers' own documentation. +

+
}> -

Web Serial is available.

+
); } +function Ports() { + const [ports, setPorts] = createSignal([]); + const [error, setError] = createSignal(); + + function remember(port: SerialPort) { + setPorts((current) => + current.some((entry) => entry.port === port) ? current : [...current, { port, info: port.getInfo() }], + ); + } + + function forget(port: SerialPort) { + setPorts((current) => current.filter((entry) => entry.port !== port)); + } + + // Solid 2.0 has no onMount; a component body runs once during setup, which is what onMount + // existed to arrange, and onCleanup is tied to the owner rather than to mounting + // + // Ports the user has already granted this origin access to, which come back without a prompt + void navigator.serial.getPorts().then((granted) => granted.forEach(remember)); + + const onConnect = (event: Event) => remember(event.target as SerialPort); + const onDisconnect = (event: Event) => forget(event.target as SerialPort); + + navigator.serial.addEventListener("connect", onConnect); + navigator.serial.addEventListener("disconnect", onDisconnect); + + onCleanup(() => { + navigator.serial.removeEventListener("connect", onConnect); + navigator.serial.removeEventListener("disconnect", onDisconnect); + }); + + async function add() { + setError(undefined); + + try { + // No filter: the old tool hardcoded the CH340's USB identifiers, which hid every other + // adapter from the picker. The user knows which one they plugged in + remember(await navigator.serial.requestPort()); + } catch (cause) { + // Dismissing the picker rejects, and is not an error worth showing + if (cause instanceof DOMException && cause.name === "NotFoundError") return; + setError(cause instanceof Error ? cause.message : String(cause)); + } + } + + return ( + <> +
+ +
+ + {(message) =>

{message()}

}
+ + 0} + fallback={ +

+ No ports yet. Choose Add a port… and pick the USB serial adapter + connected to the RS-485 bus. +

+ } + > + {(entry) => } +
+ + ); +} + function Unsupported() { return (
diff --git a/serial/src/app.css b/serial/src/app.css index 2bd1c6f..eabe9b5 100644 --- a/serial/src/app.css +++ b/serial/src/app.css @@ -11,6 +11,10 @@ --text-dim: #5c6270; --accent: #1c5fd6; --danger: #b3261e; + --danger-surface: #fce8e6; + --ok: #146c2e; + --request: #1c5fd6; + --response: #146c2e; color-scheme: light dark; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; @@ -27,6 +31,10 @@ --text-dim: #9aa2b1; --accent: #79a9ff; --danger: #f2837c; + --danger-surface: #3a1d1b; + --ok: #6ed08a; + --request: #79a9ff; + --response: #6ed08a; } } @@ -43,33 +51,302 @@ body { } main { - max-width: 70rem; + max-width: 80rem; margin-inline: auto; padding: 1.5rem 1rem 4rem; } h1 { font-size: 1.5rem; - margin-block: 0 1.5rem; + margin-block: 0 0.25rem; +} + +h2 { + font-size: 1.15rem; + margin-block: 0; +} + +h3 { + font-size: 1rem; + margin-block: 0 0.25rem; +} + +h4 { + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-dim); + margin-block: 1.5rem 0.5rem; } a { color: var(--accent); } -code { +code, +.bytes, +.address { font-family: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, monospace; - font-size: 0.9em; + font-size: 0.85em; } -.notice { +.masthead { + margin-block-end: 1.5rem; +} + +.hint { + color: var(--text-dim); + font-size: 0.85rem; +} + +p.hint { + margin-block: 0.25rem; +} + +.value { + font-weight: 600; +} + +.name { + font-weight: 500; +} + +.empty { + color: var(--text-dim); + background: var(--surface-raised); + border: 1px dashed var(--border); + border-radius: 0.5rem; + padding: 1rem 1.25rem; +} + +.notice, +.port { background: var(--surface-raised); border: 1px solid var(--border); border-radius: 0.5rem; padding: 1rem 1.25rem; + margin-block-end: 1rem; +} + +.panel-header { + display: flex; + gap: 1rem; + align-items: start; + justify-content: space-between; + flex-wrap: wrap; + margin-block-end: 0.75rem; +} + +.panel-header .actions { + display: flex; + gap: 0.5rem; +} + +/* -------------------------------------------------------------------------- */ +/* Controls */ +/* -------------------------------------------------------------------------- */ + +.controls { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 0.75rem 1rem; + margin-block-end: 0.75rem; + border: 0; + padding: 0; +} + +/* + * Each label stays with the control it names. Without this the row wraps between them and a + * "Parity" label ends up sitting above somebody else's select. + */ +.control { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +fieldset.controls { + border: 1px solid var(--border); + border-radius: 0.375rem; + padding: 0.75rem 1rem; +} + +fieldset.controls legend { + font-size: 0.8rem; + color: var(--text-dim); + padding-inline: 0.35rem; +} + +fieldset:disabled { + opacity: 0.6; +} + +label { + font-size: 0.85rem; + color: var(--text-dim); +} + +button, +select, +input { + font: inherit; + font-size: 0.9rem; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 0.375rem; + padding: 0.35rem 0.6rem; +} + +button { + cursor: pointer; +} + +button:hover:not(:disabled) { + border-color: var(--accent); +} + +button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +:is(button, select, input):focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.tabs { + display: flex; + gap: 0.25rem; + margin-block: 1rem 0.5rem; + border-block-end: 1px solid var(--border); +} + +.tabs button[aria-pressed="true"] { + border-color: var(--accent); + color: var(--accent); + font-weight: 600; +} + +/* -------------------------------------------------------------------------- */ +/* Validation */ +/* -------------------------------------------------------------------------- */ + +/* + * :user-invalid rather than :invalid, so a register write field is not flagged red the moment it + * is empty or half-typed — only once the field has been left or a submit attempted. Baseline + * since 2023, so no fallback is carried. + * + * The bounds themselves come from the register definition and are set as real min/max/step + * attributes, which is what makes the browser's own validation the source of truth here. + */ +input:user-invalid { + border-color: var(--danger); + background: var(--danger-surface); +} + +input:user-valid { + border-color: var(--ok); +} + +.error-msg { + display: none; + color: var(--danger); + font-size: 0.8rem; +} + +/* Revealed by the browser's own judgement, not by a signal */ +input:user-invalid ~ .error-msg, +.error-msg.shown { + display: block; +} + +.field { + display: flex; + align-items: center; + gap: 0.35rem; + flex-wrap: wrap; +} + +.field input[type="number"] { + width: 7rem; +} + +/* -------------------------------------------------------------------------- */ +/* Tables */ +/* -------------------------------------------------------------------------- */ + +/* Wide content scrolls inside its own box rather than pushing the page sideways */ +.table-scroll { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 0.375rem; + max-block-size: 34rem; + overflow-y: auto; +} + +table { + border-collapse: collapse; + inline-size: 100%; + font-size: 0.85rem; +} + +thead th { + position: sticky; + inset-block-start: 0; + background: var(--surface-raised); + text-align: start; + font-weight: 600; + color: var(--text-dim); + padding: 0.4rem 0.6rem; + border-block-end: 1px solid var(--border); + z-index: 1; } -.notice h2 { - font-size: 1.1rem; - margin-block-start: 0; +td { + padding: 0.35rem 0.6rem; + border-block-end: 1px solid var(--border); + vertical-align: top; +} + +tbody tr:last-child td { + border-block-end: 0; +} + +/* + * The monitor keeps hundreds of rows and only a screenful is ever in view. content-visibility lets + * the browser skip layout and paint for the rest; contain-intrinsic-size keeps the scrollbar from + * jumping as those rows are realised, and `auto` lets it remember each row's real height once + * measured. + */ +.frames tbody .row { + content-visibility: auto; + contain-intrinsic-size: auto none auto 1.9rem; +} + +.frames .row.request td:nth-child(2) { + color: var(--request); + font-weight: 600; +} + +.frames .row.response td:nth-child(2) { + color: var(--response); + font-weight: 600; +} + +.frames .row.noise { + color: var(--text-dim); + background: var(--surface-sunken); +} + +.registers .address { + white-space: nowrap; +} + +@media (max-width: 40rem) { + .panel-header { + flex-direction: column; + } } diff --git a/serial/src/serial/client.ts b/serial/src/serial/client.ts new file mode 100644 index 0000000..91906c1 --- /dev/null +++ b/serial/src/serial/client.ts @@ -0,0 +1,117 @@ +/** + * Reading and writing a device's registers over an open connection. + * + * The register definitions say what exists and what it means; this turns that into requests. The + * one piece of cleverness is batching: Modbus reads a *range*, so asking for D010 through D017 + * costs the same round trip as asking for D010 alone, and `runsOf` has already worked out which + * neighbours are worth fetching together. + */ + +import { + decodeResponse, + readCoils, + readDiscreteInputs, + readHoldingRegisters, + readInputRegisters, + writeMultipleRegisters, + writeSingleCoil, + writeSingleRegister, +} from "../modbus/pdu"; +import { runsOf, type Register, type Space } from "../devices"; +import type { Connection } from "./connection"; + +const readers: Record Uint8Array> = { + input: readInputRegisters, + holding: readHoldingRegisters, + coil: readCoils, + discreteInput: readDiscreteInputs, +}; + +/** A read that the device refused, kept apart from a read that produced a value */ +export type Refusal = { start: number; quantity: number; message: string }; + +export type ReadResult = { + /** Raw sixteen-bit values by address. Coils and discrete inputs come back as 0 or 1 */ + values: Map; + /** Ranges the device would not answer, so the UI can say which ones and why */ + refused: Refusal[]; +}; + +/** + * Reads every register in `registers`, which must all be in `space`, in as few requests as the + * device's limits allow. + * + * A refusal is recorded and the remaining runs are still attempted: one unreadable register + * should not cost the reading of every other one, and the RadiCal does refuse individual + * addresses when a variant does not implement them + */ +export async function readAll( + connection: Connection, + address: number, + space: Space, + registers: readonly Register[], + options: { timeoutMs?: number } = {}, +): Promise { + const values = new Map(); + const refused: Refusal[] = []; + const read = readers[space]; + + for (const run of runsOf(registers)) { + try { + const response = await connection.request(read(address, run.start, run.quantity), options.timeoutMs); + const decoded = decodeResponse(response); + + if (decoded?.kind === "registers") { + decoded.values.forEach((value, index) => values.set(run.start + index, value)); + continue; + } + + if (decoded?.kind === "bits") { + decoded.values.slice(0, run.quantity).forEach((value, index) => values.set(run.start + index, value ? 1 : 0)); + continue; + } + + refused.push({ + ...run, + message: decoded?.kind === "exception" ? decoded.text : "The device answered with something unexpected", + }); + } catch (error) { + refused.push({ ...run, message: error instanceof Error ? error.message : String(error) }); + } + } + + return { values, refused }; +} + +/** + * Writes one register, choosing the function code the register's address space calls for. + * + * The relay is why this is not simply "function code 6": its device address is a holding register + * that the manual only ever writes with 0x10, and its relay is a coil rather than a register at + * all. `useMultiple` covers the first of those + */ +export async function write( + connection: Connection, + address: number, + register: Register, + raw: number, + options: { timeoutMs?: number; useMultiple?: boolean; writeAddress?: number } = {}, +): Promise { + // Some devices read a setting from one address and write it to another — see the relay's baud + // rate, which is read from 0x03E8 and written to 0x03E9 + const target = options.writeAddress ?? register.address; + + const frame = + register.space === "coil" + ? writeSingleCoil(address, target, raw !== 0) + : options.useMultiple + ? writeMultipleRegisters(address, target, [raw]) + : writeSingleRegister(address, target, raw); + + const response = await connection.request(frame, options.timeoutMs); + const decoded = decodeResponse(response); + + if (decoded?.kind === "exception") { + throw new Error(`The device refused the write: ${decoded.text}`); + } +} diff --git a/serial/src/serial/connection.test.ts b/serial/src/serial/connection.test.ts new file mode 100644 index 0000000..46d1bfe --- /dev/null +++ b/serial/src/serial/connection.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { bytes, relay, temperatureSensor } from "../modbus/frames.fixture"; +import { findResponse } from "./connection"; + +/** The request each fixture response answers, for the echo checks */ +const readBaud = relay.readBaudRequest; + +describe("findResponse", () => { + it("finds the answer to the request that was sent", () => { + const found = findResponse(relay.readBaudResponse, 0xff, 0x03, readBaud); + + expect(found).toEqual(relay.readBaudResponse); + }); + + it("waits while the answer could still be incomplete", () => { + expect(findResponse(relay.readBaudResponse.subarray(0, 4), 0xff, 0x03, readBaud)).toBeUndefined(); + }); + + /** + * Some RS-485 adapters put what they transmit back on the receive line — `debug-listener` has a + * flag for it. The echo is a perfectly valid frame with the right address and function code, so + * nothing but recognising the bytes themselves keeps it from being returned as the answer + */ + it("steps over the echo of the request", () => { + const withEcho = concat(readBaud, relay.readBaudResponse); + const found = findResponse(withEcho, 0xff, 0x03, readBaud); + + expect(found).toEqual(relay.readBaudResponse); + }); + + it("returns nothing while only the echo has come back", () => { + expect(findResponse(readBaud, 0xff, 0x03, readBaud)).toBeUndefined(); + }); + + it("ignores traffic to and from other devices", () => { + const busy = concat(temperatureSensor.readBothRequest, temperatureSensor.readBothResponse, relay.readBaudResponse); + const found = findResponse(busy, 0xff, 0x03, readBaud); + + expect(found).toEqual(relay.readBaudResponse); + }); + + it("ignores an answer to a different function code", () => { + // A read-coils response from the same device is not the answer to a read-holding-registers + expect(findResponse(relay.readRelayStateResponse, 0xff, 0x03, readBaud)).toBeUndefined(); + }); + + /** An exception is the device answering, not the device staying silent */ + it("accepts the exception form of the function code as the answer", () => { + const exception = withCrc(bytes("FF 83 02 00 00")); + const found = findResponse(exception, 0xff, 0x03, readBaud); + + expect(found).toEqual(exception); + }); + + it("skips leading rubbish rather than giving up on the whole buffer", () => { + const found = findResponse(concat(bytes("00 11 22"), relay.readBaudResponse), 0xff, 0x03, readBaud); + + expect(found).toEqual(relay.readBaudResponse); + }); + + it("copies the answer out of the working buffer", () => { + const buffer = concat(relay.readBaudResponse); + const found = findResponse(buffer, 0xff, 0x03, readBaud)!; + + buffer.fill(0); + + expect(Array.from(found)).toEqual(Array.from(relay.readBaudResponse)); + }); +}); + +function withCrc(frame: Uint8Array): Uint8Array { + const table = new Uint16Array(256); + for (let byte = 0; byte < 256; byte++) { + let crc = byte; + for (let bit = 0; bit < 8; bit++) crc = crc & 1 ? (crc >> 1) ^ 0xa001 : crc >> 1; + table[byte] = crc; + } + + let crc = 0xffff; + for (let index = 0; index < frame.length - 2; index++) crc = (crc >> 8) ^ table[(crc ^ frame[index]!) & 0xff]!; + + const out = new Uint8Array(frame); + out[out.length - 2] = crc & 0xff; + out[out.length - 1] = crc >> 8; + return out; +} + +function concat(...parts: Uint8Array[]): Uint8Array { + const joined = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)); + let offset = 0; + + for (const part of parts) { + joined.set(part, offset); + offset += part.length; + } + + return joined; +} diff --git a/serial/src/serial/connection.ts b/serial/src/serial/connection.ts new file mode 100644 index 0000000..8812fc9 --- /dev/null +++ b/serial/src/serial/connection.ts @@ -0,0 +1,239 @@ +/** + * One open serial port, and the two things this tool does with it. + * + * There is exactly one reader on a port, so both jobs are served from a single read loop: + * every chunk goes to the subscribers (which is how the monitor sees traffic) and, while a + * request is outstanding, to whatever is waiting for a reply. + * + * Watching and talking are still mutually exclusive at the level above this — a half-duplex + * RS-485 line has room for one master, and if the fan controller is already polling, this tool + * must not also be issuing requests. `connection.ts` does not enforce that; it just makes both + * possible on the same port. + */ + +import { isValid } from "../modbus/crc"; +import { responseLength } from "../modbus/pdu"; + +export type Parity = "none" | "even" | "odd"; + +export type PortSettings = { + baudRate: number; + parity: Parity; + dataBits: 7 | 8; + stopBits: 1 | 2; +}; + +/** What fan-controller and the RadiCal manual both use */ +export const defaultSettings: PortSettings = { + baudRate: 19_200, + parity: "even", + dataBits: 8, + stopBits: 1, +}; + +/** Bit rates offered in the UI. The RadiCal supports all of these; other devices support fewer */ +export const baudRates = [1200, 2400, 4800, 9600, 14_400, 19_200, 38_400, 57_600, 115_200] as const; + +export type Chunk = { bytes: Uint8Array; at: number }; +export type Subscriber = (chunk: Chunk) => void; + +export class RequestFailed extends Error { + constructor( + message: string, + readonly reason: "timeout" | "closed", + ) { + super(message); + this.name = "RequestFailed"; + } +} + +export class Connection { + #reader: ReadableStreamDefaultReader | undefined; + #writer: WritableStreamDefaultWriter | undefined; + #subscribers = new Set(); + #reading: Promise | undefined; + #closing = false; + + constructor(readonly port: SerialPort) {} + + get isOpen(): boolean { + return this.#reader !== undefined; + } + + async open(settings: PortSettings): Promise { + if (this.isOpen) return; + + await this.port.open({ + baudRate: settings.baudRate, + parity: settings.parity, + dataBits: settings.dataBits, + stopBits: settings.stopBits, + }); + + if (!this.port.readable || !this.port.writable) { + throw new Error("The port opened without a readable or writable stream"); + } + + this.#closing = false; + this.#reader = this.port.readable.getReader(); + this.#writer = this.port.writable.getWriter(); + this.#reading = this.#readLoop(); + } + + async close(): Promise { + if (!this.isOpen) return; + + this.#closing = true; + + // Cancelling makes the pending read() resolve, which is what lets the loop finish. Without it + // the loop stays parked on a read that never completes and the port cannot be released + await this.#reader?.cancel().catch(() => undefined); + await this.#reading?.catch(() => undefined); + + this.#reader?.releaseLock(); + this.#writer?.releaseLock(); + this.#reader = undefined; + this.#writer = undefined; + + await this.port.close().catch(() => undefined); + } + + subscribe(subscriber: Subscriber): () => void { + this.#subscribers.add(subscriber); + return () => this.#subscribers.delete(subscriber); + } + + /** + * Sends a frame and waits for the device's answer. + * + * The reply is found by checksum rather than by counting bytes, which also steps over the echo + * of the request itself: some RS-485 adapters put what they transmit back on the receive line, + * and `debug-listener` has a flag for exactly that. An echo is a valid frame, so it has to be + * ruled out by shape — same address, but the request's shape rather than a response's + */ + async request(frame: Uint8Array, timeoutMs = 1_000): Promise { + const writer = this.#writer; + if (!writer) throw new RequestFailed("The port is not open", "closed"); + + const address = frame[0]!; + const functionCode = frame[1]!; + + const reply = this.#awaitResponse(address, functionCode, frame, timeoutMs); + await writer.write(frame); + + return reply; + } + + #awaitResponse( + address: number, + functionCode: number, + sent: Uint8Array, + timeoutMs: number, + ): Promise { + return new Promise((resolve, reject) => { + let pending: Uint8Array = new Uint8Array(0); + + const timer = setTimeout(() => { + finish(); + reject( + new RequestFailed( + `No reply from address ${address} within ${timeoutMs} ms`, + "timeout", + ), + ); + }, timeoutMs); + + const unsubscribe = this.subscribe(({ bytes }) => { + pending = concat(pending, bytes); + + const found = findResponse(pending, address, functionCode, sent); + if (!found) return; + + finish(); + resolve(found); + }); + + function finish() { + clearTimeout(timer); + unsubscribe(); + } + }); + } + + async #readLoop(): Promise { + const reader = this.#reader; + if (!reader) return; + + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + if (!value || value.length === 0) continue; + + const chunk: Chunk = { bytes: value, at: performance.now() }; + // Copied before iterating, so a subscriber that unsubscribes itself — which is exactly + // what a settled request does — cannot disturb the walk + for (const subscriber of [...this.#subscribers]) subscriber(chunk); + } + } catch (error) { + // A cancel during close surfaces here as a rejection and is not worth reporting + if (!this.#closing) throw error; + } + } +} + +/** + * Finds the device's answer in what has arrived so far. + * + * Returns `undefined` while the answer could still be incomplete, so the caller keeps waiting + * rather than deciding early + */ +export function findResponse( + buffer: Uint8Array, + address: number, + functionCode: number, + sent: Uint8Array, +): Uint8Array | undefined { + for (let offset = 0; offset + 4 <= buffer.length; offset++) { + if (buffer[offset] !== address) continue; + + const ahead = buffer.subarray(offset); + const code = ahead[1]; + + // Either the answer to what was asked, or the exception form of it + if (code !== functionCode && code !== (functionCode | 0x80)) continue; + + const length = responseLength(ahead); + if (typeof length !== "number" || ahead.length < length) continue; + + const candidate = ahead.subarray(0, length); + if (!isValid(candidate)) continue; + + // The echo of our own request is a valid frame with the right address and function code, so + // it has to be recognised and skipped rather than returned as an answer + if (equal(candidate, sent)) continue; + + return new Uint8Array(candidate); + } + + return undefined; +} + +function equal(left: Uint8Array, right: Uint8Array): boolean { + if (left.length !== right.length) return false; + + for (let index = 0; index < left.length; index++) { + if (left[index] !== right[index]) return false; + } + + return true; +} + +function concat(left: Uint8Array, right: Uint8Array): Uint8Array { + if (left.length === 0) return new Uint8Array(right); + + const joined = new Uint8Array(left.length + right.length); + joined.set(left); + joined.set(right, left.length); + return joined; +} diff --git a/serial/src/ui/Devices.tsx b/serial/src/ui/Devices.tsx new file mode 100644 index 0000000..6e8bfce --- /dev/null +++ b/serial/src/ui/Devices.tsx @@ -0,0 +1,341 @@ +import { For, Show, createMemo, createSignal } from "solid-js"; +import { + devices, + noContext, + referencesFor, + registersIn, + type Context, + type Device, + type Register, +} from "../devices"; +import { BAUD_RATE_WRITE, BAUD_RATE_READ, relay } from "../devices/relay"; +import { readAll, write } from "../serial/client"; +import type { Connection } from "../serial/connection"; +import { registerAddress, toHex } from "./format"; + +type State = { + values: Map; + refused: { start: number; quantity: number; message: string }[]; + at?: Date; +}; + +export default function Devices(props: { connection: Connection }) { + const [device, setDevice] = createSignal(devices[0]!); + const [address, setAddress] = createSignal(devices[0]!.defaults.address); + const [state, setState] = createSignal({ values: new Map(), refused: [] }); + const [busy, setBusy] = createSignal(false); + const [error, setError] = createSignal(); + + const context = createMemo(() => ({ holding: state().values })); + + function chooseDevice(id: string) { + const chosen = devices.find((entry) => entry.id === id); + if (!chosen) return; + + setDevice(chosen); + setAddress(chosen.defaults.address); + setState({ values: new Map(), refused: [] }); + } + + async function readEverything() { + setBusy(true); + setError(undefined); + + try { + const values = new Map(); + const refused: State["refused"] = []; + + // The references first, and on their own: almost every RadiCal input register is a fraction + // of one of them, so reading them last would mean a first pass that can state nothing + const references = referencesFor(device().registers); + if (references.length > 0) { + const result = await readAll( + props.connection, + address(), + "holding", + references.map((reference) => ({ address: reference }) as Register), + ); + for (const [key, value] of result.values) values.set(key, value); + } + + for (const space of ["holding", "input", "coil", "discreteInput"] as const) { + const registers = registersIn(device(), space); + if (registers.length === 0) continue; + + const result = await readAll(props.connection, address(), space, registers); + for (const [key, value] of result.values) values.set(key, value); + refused.push(...result.refused); + } + + setState({ values, refused, at: new Date() }); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setBusy(false); + } + } + + return ( +
+
+
+

Device

+

+ Active. This sends requests, so do not use it while the fan controller is driving the + same bus — two masters on one RS-485 line collide. +

+
+
+ +
+
+ + +
+ +
+ + {/* The hint sits above the field, so an autocomplete popover cannot cover it */} + + {device().defaults.addressRange[0]}–{device().defaults.addressRange[1]}, default{" "} + {device().defaults.address} + + setAddress(event.currentTarget.valueAsNumber)} + /> +
+ + +
+ +

+ Documented in {device().documentation}. Expects {device().defaults.baudRate} baud,{" "} + {device().defaults.parity} parity — set the port to match before reading. +

+ + {(message) =>

{message()}

}
+ + + {(at) =>

Last read {at().toLocaleTimeString()}

} +
+ + + {(refusal) => ( +

+ {registerAddress(refusal.start)} +{refusal.quantity}: {refusal.message} +

+ )} +
+ + + {(space) => ( + 0}> +

{spaceHeading[space]}

+
+ + + + + + + + + + + + {(register) => ( + void readEverything()} + /> + )} + + +
AddressNameValueWrite
+
+
+ )} +
+
+ ); +} + +const spaceHeading = { + input: "Input registers — read only", + holding: "Holding registers — read and write", + coil: "Coils — read and write", + discreteInput: "Discrete inputs — read only", +} as const; + +function RegisterRow(props: { + connection: Connection; + deviceAddress: number; + register: Register; + raw: number | undefined; + context: Context; + onWritten: () => void; +}) { + const [pending, setPending] = createSignal(); + const [failure, setFailure] = createSignal(); + const [writing, setWriting] = createSignal(false); + // The browser already knows whether the value is within the min/max/step the register declares, + // so its judgement is the one used rather than a second copy of the bounds check here + const [valid, setValid] = createSignal(false); + let field: HTMLInputElement | undefined; + + const decoded = createMemo(() => { + const raw = props.raw; + return raw === undefined ? undefined : props.register.decode(raw, props.context ?? noContext); + }); + + async function send() { + const value = pending(); + const writable = props.register.write; + if (value === undefined || !writable) return; + + // Refuse to put a value on the bus that the device is bound to reject anyway + if (field && !field.validity.valid) { + field.reportValidity(); + return; + } + + setWriting(true); + setFailure(undefined); + + try { + await write(props.connection, props.deviceAddress, props.register, writable.encode(value), { + // The relay's manual only ever writes its holding registers with 0x10, and reads its baud + // rate from a different address than it writes it to + useMultiple: props.register.space === "holding" && isRelayRegister(props.register), + writeAddress: props.register.address === BAUD_RATE_READ && isRelayRegister(props.register) ? BAUD_RATE_WRITE : undefined, + }); + + props.onWritten(); + } catch (cause) { + setFailure(cause instanceof Error ? cause.message : String(cause)); + } finally { + setWriting(false); + } + } + + const inputId = () => `write-${props.register.space}-${props.register.address}`; + + return ( + + + {registerAddress(props.register.address)} + §{props.register.reference.replace(/^docs\/.*, /, "")} + + + {props.register.name} + {(gloss) => — {gloss()}} + + + not read}> + {(value) => ( + <> + {value().text} + + {(reason) => — {(reason() as { because: string }).because}} + + + raw 0x{toHex(props.raw!, 4)} + + + )} + + + + —}> + {(writable) => ( +
+ { setPending(props.raw ? 0 : 1); void send(); }}> + {props.raw ? "Open" : "Close"} + + } + > + {(numeric) => ( + <> + { + setPending(event.currentTarget.valueAsNumber); + setValid(event.currentTarget.validity.valid); + }} + /> + {/* Revealed by :user-invalid, so nothing is flagged until the field is left */} + + {(numeric() as { min: number }).min} to {(numeric() as { max: number }).max} + + + )} + + } + > + {(choice) => ( + + )} + + + + + + + {(message) => {message()}} +
+ )} +
+ + + ); +} + +function isRelayRegister(register: Register): boolean { + return relay.registers.includes(register); +} diff --git a/serial/src/ui/Monitor.tsx b/serial/src/ui/Monitor.tsx new file mode 100644 index 0000000..8cd2eb3 --- /dev/null +++ b/serial/src/ui/Monitor.tsx @@ -0,0 +1,136 @@ +import { For, Show, createEffect, createSignal, onCleanup } from "solid-js"; +import { BusMonitor, type MonitorEvent, type Observed } from "../modbus/monitor"; +import type { Connection } from "../serial/connection"; +import { describe, elapsed, frameToHex, registerAddress, toHex } from "./format"; + +/** + * How many events are kept. A busy bus produces a few hundred frames a minute, and the interesting + * part is nearly always the recent end, so the list is capped rather than grown without limit + */ +const KEEP = 500; + +export default function Monitor(props: { connection: Connection }) { + const [events, setEvents] = createSignal([]); + const [paused, setPaused] = createSignal(false); + const [origin, setOrigin] = createSignal(); + + // Solid 2.0 splits an effect into a compute and an effect function: the first tracks, the second + // acts on what it produced. Here the tracked value is the connection, and re-subscribing when it + // changes is the side effect — the returned function tears the old subscription down + createEffect( + () => props.connection, + (connection) => { + const monitor = new BusMonitor(); + + const unsubscribe = connection.subscribe(({ bytes, at }) => { + if (paused()) return; + + const produced = monitor.push(bytes, at); + if (produced.length === 0) return; + + setOrigin((current) => current ?? produced[0]!.at); + setEvents((current) => [...current, ...produced].slice(-KEEP)); + }); + + onCleanup(unsubscribe); + }, + ); + + return ( +
+
+
+

Bus monitor

+

+ Passive. Nothing is written to the port, so this is safe to leave running while the fan + controller drives the bus. +

+
+ +
+ + +
+
+ + 0} + fallback={ +

+ Nothing on the bus yet. Frames appear here as soon as something transmits — check the + baud rate and parity if the controller is running and this stays empty. +

+ } + > +
+ + + + + + + + + + + + + {(event) => } + +
TimeFromAddressFunctionMeaningBytes
+
+
+
+ ); +} + +function Row(props: { event: MonitorEvent; origin: number }) { + return ( + + {elapsed(props.event.at, props.origin)} + Unaccounted bytes — the monitor could not fit these into a frame + {frameToHex(props.event.bytes)} + + } + > + {(frame) => ( + + {elapsed(frame().at, props.origin)} + {frame().role === "request" ? "Master" : "Device"} + {frame().address} + 0x{toHex(frame().functionCode)} + + {describe(frame().decoded)} + {/* + A read response carries values but not the addresses they came from. Where the + monitor managed to pair it with its request, say which range they belong to + */} + + {(start) => — from {registerAddress(start())}} + + + {frameToHex(frame().bytes)} + + )} + + ); +} + +/** + * The start address a paired request was asking about, when it was a read. + * + * Narrowing here rather than inline, because `decoded` is a union in which only some members have + * a `start` at all and TypeScript cannot follow that through a call inside a ternary + */ +function answeredRange(decoded: Observed["decoded"] | undefined): number | undefined { + if (!decoded) return undefined; + + return decoded.kind === "read" ? decoded.start : undefined; +} diff --git a/serial/src/ui/PortPanel.tsx b/serial/src/ui/PortPanel.tsx new file mode 100644 index 0000000..1791051 --- /dev/null +++ b/serial/src/ui/PortPanel.tsx @@ -0,0 +1,132 @@ +import { For, Show, createSignal } from "solid-js"; +import { Connection, baudRates, defaultSettings, type Parity, type PortSettings } from "../serial/connection"; +import Devices from "./Devices"; +import Monitor from "./Monitor"; + +/** + * Settings that make a device answer at all. + * + * The RadiCal manual recommends 19200 8E1 and `fan-controller` is built for it; both the relay and + * the temperature sensor default to 9600 with no parity. A port speaks one of these at a time, so + * a bus carrying both kinds of device has to be visited twice + */ +const presets: readonly { label: string; settings: PortSettings }[] = [ + { label: "RadiCal fans — 19200 8E1", settings: defaultSettings }, + { + label: "Relay / temperature sensor — 9600 8N1", + settings: { baudRate: 9600, parity: "none", dataBits: 8, stopBits: 1 }, + }, +]; + +type Mode = "monitor" | "devices"; + +export default function PortPanel(props: { port: SerialPort; info: Partial }) { + const connection = new Connection(props.port); + + const [settings, setSettings] = createSignal(defaultSettings); + const [isOpen, setIsOpen] = createSignal(false); + const [mode, setMode] = createSignal("monitor"); + const [error, setError] = createSignal(); + + async function toggle() { + setError(undefined); + + try { + if (isOpen()) { + await connection.close(); + setIsOpen(false); + return; + } + + await connection.open(settings()); + setIsOpen(true); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } + } + + function update(key: K, value: PortSettings[K]) { + setSettings((current) => ({ ...current, [key]: value })); + } + + return ( +
+
+
+

+ Serial port + + + {" "} + USB {props.info.usbVendorId?.toString(16)}:{props.info.usbProductId?.toString(16)} + + +

+
+ + +
+ + {(message) =>

{message()}

}
+ +
+ Port settings + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + Open the port to watch the bus or read a device.

} + > + + + }> + + +
+
+ ); +} diff --git a/serial/src/ui/format.ts b/serial/src/ui/format.ts new file mode 100644 index 0000000..d9651a4 --- /dev/null +++ b/serial/src/ui/format.ts @@ -0,0 +1,79 @@ +import type { Request, Response } from "../modbus/pdu"; + +export function toHex(value: number, width = 2): string { + return value.toString(16).toUpperCase().padStart(width, "0"); +} + +/** Frames read best as space-separated hex pairs, the way both manuals print them */ +export function frameToHex(frame: Uint8Array): string { + return Array.from(frame, (byte) => toHex(byte)).join(" "); +} + +/** + * A register address, written the way the device's own documentation writes it. + * + * The RadiCal manual uses `D010`, and its address space is documented as D000 … D614 — so that + * range, and only that range, is rendered in its style. Every other device's manual uses plain + * hex, and the relay in particular has registers at 0x03E8 that would read as nonsense with a + * `D` in front of them + */ +export function registerAddress(address: number): string { + const isRadicalSpace = address >= 0xd000 && address <= 0xd614; + + return isRadicalSpace ? `D${toHex(address, 4).slice(1)}` : `0x${toHex(address, 4)}`; +} + +const spaceNames: Record = { + coils: ["coil", "coils"], + discreteInputs: ["discrete input", "discrete inputs"], + holdingRegisters: ["holding register", "holding registers"], + inputRegisters: ["input register", "input registers"], +}; + +/** Reading "1 holding registers" is a small thing, but it is the kind of small thing that grates */ +function count(of: string, quantity: number): string { + const names = spaceNames[of]; + if (!names) return of; + + return quantity === 1 ? names[0] : names[1]; +} + +/** A one-line description of what a frame says, for the monitor's summary column */ +export function describe(decoded: Request | Response | null): string { + if (decoded === null) return "Not decoded"; + + switch (decoded.kind) { + case "read": + return `Read ${decoded.quantity} ${count(decoded.of, decoded.quantity)} from ${registerAddress(decoded.start)}`; + + case "writeSingleCoil": + return `Write coil ${registerAddress(decoded.address)} ${decoded.on ? "closed" : "open"}`; + + case "writeSingleRegister": + return `Write ${registerAddress(decoded.address)} = ${decoded.value}`; + + case "writeMultipleCoils": + return `Write ${decoded.quantity} ${count("coils", decoded.quantity)} from ${registerAddress(decoded.start)}`; + + case "writeMultipleRegisters": + return `Write ${decoded.values.length} ${count("holdingRegisters", decoded.values.length)} from ${registerAddress(decoded.start)}`; + + case "registers": + return `${decoded.values.length} ${count(decoded.of, decoded.values.length)}: ${decoded.values.map((value) => toHex(value, 4)).join(" ")}`; + + case "bits": + return `${count(decoded.of, 2)}: ${decoded.values.map((value) => (value ? "1" : "0")).join("")}`; + + case "writeAcknowledged": + return `Acknowledged ${decoded.quantity} from ${registerAddress(decoded.start)}`; + + case "exception": + return `Exception ${toHex(decoded.code)} — ${decoded.text}`; + } +} + +/** Milliseconds since the page loaded, shown relative to the first frame seen */ +export function elapsed(at: number, since: number): string { + const seconds = (at - since) / 1000; + return `${seconds.toFixed(3)} s`; +} -- 2.51.2