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`; +}