Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/SantaClaas/embedded-fan-control.
Something went wrong. Try again.
7.3 kB · 239 lines
TypeScript
at serial-ui
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240/** * 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<Uint8Array> | undefined; #writer: WritableStreamDefaultWriter<Uint8Array> | undefined; #subscribers = new Set<Subscriber>(); #reading: Promise<void> | undefined; #closing = false;
constructor(readonly port: SerialPort) {}
get isOpen(): boolean { return this.#reader !== undefined; }
async open(settings: PortSettings): Promise<void> { 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<void> { 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<Uint8Array> { 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<Uint8Array> { 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<void> { 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;}