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.
+
+ { 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.
+
+ {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()}
}
+
+
+
+ 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`;
+}