Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/SantaClaas/embedded-fan-control.
Something went wrong. Try again.
12 kB · 316 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317/** * Modbus RTU frames: how long one is, and what it says. * * A frame is `address · function code · payload · CRC-16`. Nothing in it records its own length, * which is the whole difficulty of reading a bus you are not driving — see `monitor.ts`. What the * frame does carry is a function code, and for every code the length is either fixed or written * into one byte near the front. That is what `requestLength` and `responseLength` exploit. * * Request and response are *different shapes for the same function code*, and neither says which * it is. A read-holding-registers request is always 8 bytes; the response to it is 5 plus however * many bytes it carries. Telling them apart is the monitor's job, not this module's. */
import { crc16, read as readCrc } from "./crc";
export const FunctionCode = { ReadCoils: 0x01, ReadDiscreteInputs: 0x02, ReadHoldingRegisters: 0x03, ReadInputRegisters: 0x04, WriteSingleCoil: 0x05, WriteSingleRegister: 0x06, WriteMultipleCoils: 0x0f, WriteMultipleRegisters: 0x10,} as const;
export type FunctionCode = (typeof FunctionCode)[keyof typeof FunctionCode];
/** Set in the function code of a response that is reporting a refusal rather than an answer */export const EXCEPTION_FLAG = 0x80;
/** The value written to a coil to close it. Anything else, in practice 0x0000, opens it */export const COIL_ON = 0xff00;export const COIL_OFF = 0x0000;
/** * Modbus' own exception codes, plus the two the RadiCal manual documents beyond the standard * meanings (section 1.3.1). The fan answers 0x02 for a register outside D000…D614 and for a read * of more than 37 registers, and 0x04 when the electronics cannot read a register at all */export const exceptionText: Readonly<Record<number, string>> = { 0x01: "Illegal function", 0x02: "Illegal data address", 0x03: "Illegal data value", 0x04: "Device failure", 0x05: "Acknowledge", 0x06: "Device busy", 0x08: "Memory parity error", 0x0a: "Gateway path unavailable", 0x0b: "Gateway target failed to respond",};
/** * How long a frame is, once its function code and any count byte have been seen. * * `"incomplete"` means the bytes that carry the length have not arrived yet and the caller should * ask again with more. `"unknown"` means the function code is not one this module can size — the * RadiCal's serial-number-addressed commands (0x43, 0x44, 0x46, 0x50) are the ones that turn up in * practice — and the caller cannot do better than resynchronise */export type FrameLength = number | "incomplete" | "unknown";
/** A read or a single write, in either direction, is always this long */const FIXED_LENGTH = 8;
export function requestLength(buffer: Uint8Array): FrameLength { if (buffer.length < 2) return "incomplete";
switch (buffer[1]) { case FunctionCode.ReadCoils: case FunctionCode.ReadDiscreteInputs: case FunctionCode.ReadHoldingRegisters: case FunctionCode.ReadInputRegisters: case FunctionCode.WriteSingleCoil: case FunctionCode.WriteSingleRegister: return FIXED_LENGTH;
// address · code · start(2) · quantity(2) · byte count · payload · CRC(2) case FunctionCode.WriteMultipleCoils: case FunctionCode.WriteMultipleRegisters: { if (buffer.length < 7) return "incomplete"; return 7 + buffer[6]! + 2; }
default: return "unknown"; }}
export function responseLength(buffer: Uint8Array): FrameLength { if (buffer.length < 2) return "incomplete";
const code = buffer[1]!;
// address · code · exception · CRC(2). Checked before the switch because the flag makes the // function code an entirely different number if (code & EXCEPTION_FLAG) return 5;
switch (code) { // address · code · byte count · payload · CRC(2) case FunctionCode.ReadCoils: case FunctionCode.ReadDiscreteInputs: case FunctionCode.ReadHoldingRegisters: case FunctionCode.ReadInputRegisters: { if (buffer.length < 3) return "incomplete"; return 3 + buffer[2]! + 2; }
// A write is answered by echoing the header back case FunctionCode.WriteSingleCoil: case FunctionCode.WriteSingleRegister: case FunctionCode.WriteMultipleCoils: case FunctionCode.WriteMultipleRegisters: return FIXED_LENGTH;
default: return "unknown"; }}
export type Request = | { kind: "read"; of: "coils" | "discreteInputs" | "holdingRegisters" | "inputRegisters"; start: number; quantity: number } | { kind: "writeSingleCoil"; address: number; on: boolean } | { kind: "writeSingleRegister"; address: number; value: number } | { kind: "writeMultipleCoils"; start: number; quantity: number; values: boolean[] } | { kind: "writeMultipleRegisters"; start: number; values: number[] };
export type Response = | { kind: "bits"; of: "coils" | "discreteInputs"; values: boolean[] } | { kind: "registers"; of: "holdingRegisters" | "inputRegisters"; values: number[] } | { kind: "writeSingleCoil"; address: number; on: boolean } | { kind: "writeSingleRegister"; address: number; value: number } | { kind: "writeAcknowledged"; start: number; quantity: number } | { kind: "exception"; functionCode: number; code: number; text: string };
const readTarget: Readonly<Record<number, "coils" | "discreteInputs" | "holdingRegisters" | "inputRegisters">> = { [FunctionCode.ReadCoils]: "coils", [FunctionCode.ReadDiscreteInputs]: "discreteInputs", [FunctionCode.ReadHoldingRegisters]: "holdingRegisters", [FunctionCode.ReadInputRegisters]: "inputRegisters",};
/** * Decodes the payload of a frame already known to be complete and CRC-valid. * * Returns `null` for a function code it does not understand rather than throwing, because the * monitor shows unparseable traffic as raw bytes instead of dropping it */export function decodeRequest(frame: Uint8Array): Request | null { const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength); const code = frame[1]!;
const target = readTarget[code]; if (target !== undefined) { return { kind: "read", of: target, start: view.getUint16(2), quantity: view.getUint16(4) }; }
switch (code) { case FunctionCode.WriteSingleCoil: return { kind: "writeSingleCoil", address: view.getUint16(2), on: view.getUint16(4) === COIL_ON };
case FunctionCode.WriteSingleRegister: return { kind: "writeSingleRegister", address: view.getUint16(2), value: view.getUint16(4) };
case FunctionCode.WriteMultipleCoils: { const quantity = view.getUint16(4); return { kind: "writeMultipleCoils", start: view.getUint16(2), quantity, values: unpackBits(frame, 7, quantity) }; }
case FunctionCode.WriteMultipleRegisters: { const count = frame[6]!; const values: number[] = []; for (let offset = 0; offset + 1 < count; offset += 2) values.push(view.getUint16(7 + offset)); return { kind: "writeMultipleRegisters", start: view.getUint16(2), values }; }
default: return null; }}
export function decodeResponse(frame: Uint8Array): Response | null { const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength); const code = frame[1]!;
if (code & EXCEPTION_FLAG) { const exception = frame[2]!; return { kind: "exception", functionCode: code & ~EXCEPTION_FLAG, code: exception, text: exceptionText[exception] ?? `Unknown exception 0x${exception.toString(16).padStart(2, "0")}`, }; }
switch (code) { case FunctionCode.ReadCoils: case FunctionCode.ReadDiscreteInputs: { const count = frame[2]!; return { kind: "bits", of: code === FunctionCode.ReadCoils ? "coils" : "discreteInputs", // Without the request there is no way to know how many of the trailing bits are padding, // so every bit of every byte is reported and the caller takes the ones it asked for values: unpackBits(frame, 3, count * 8), }; }
case FunctionCode.ReadHoldingRegisters: case FunctionCode.ReadInputRegisters: { const count = frame[2]!; const values: number[] = []; for (let offset = 0; offset + 1 < count; offset += 2) values.push(view.getUint16(3 + offset)); return { kind: "registers", of: code === FunctionCode.ReadHoldingRegisters ? "holdingRegisters" : "inputRegisters", values, }; }
case FunctionCode.WriteSingleCoil: return { kind: "writeSingleCoil", address: view.getUint16(2), on: view.getUint16(4) === COIL_ON };
case FunctionCode.WriteSingleRegister: return { kind: "writeSingleRegister", address: view.getUint16(2), value: view.getUint16(4) };
case FunctionCode.WriteMultipleCoils: case FunctionCode.WriteMultipleRegisters: return { kind: "writeAcknowledged", start: view.getUint16(2), quantity: view.getUint16(4) };
default: return null; }}
/** Modbus packs bits low-bit-first within each byte, starting at the lowest address */function unpackBits(frame: Uint8Array, offset: number, quantity: number): boolean[] { const values: boolean[] = [];
for (let index = 0; index < quantity; index++) { const byte = frame[offset + (index >> 3)]; if (byte === undefined) break; values.push((byte & (1 << (index & 7))) !== 0); }
return values;}
/* -------------------------------------------------------------------------- *//* Building frames *//* -------------------------------------------------------------------------- */
/** `address · code · start · quantity · CRC`, the shape of every read and single write */function fixedFrame(address: number, code: number, first: number, second: number): Uint8Array { const frame = new Uint8Array(FIXED_LENGTH); const view = new DataView(frame.buffer);
frame[0] = address; frame[1] = code; view.setUint16(2, first); view.setUint16(4, second); writeCrc(frame);
return frame;}
export function readCoils(address: number, start: number, quantity: number): Uint8Array { return fixedFrame(address, FunctionCode.ReadCoils, start, quantity);}
export function readDiscreteInputs(address: number, start: number, quantity: number): Uint8Array { return fixedFrame(address, FunctionCode.ReadDiscreteInputs, start, quantity);}
export function readHoldingRegisters(address: number, start: number, quantity: number): Uint8Array { return fixedFrame(address, FunctionCode.ReadHoldingRegisters, start, quantity);}
export function readInputRegisters(address: number, start: number, quantity: number): Uint8Array { return fixedFrame(address, FunctionCode.ReadInputRegisters, start, quantity);}
export function writeSingleCoil(address: number, coil: number, on: boolean): Uint8Array { return fixedFrame(address, FunctionCode.WriteSingleCoil, coil, on ? COIL_ON : COIL_OFF);}
export function writeSingleRegister(address: number, register: number, value: number): Uint8Array { return fixedFrame(address, FunctionCode.WriteSingleRegister, register, value);}
export function writeMultipleRegisters(address: number, start: number, values: readonly number[]): Uint8Array { const count = values.length * 2; const frame = new Uint8Array(9 + count); const view = new DataView(frame.buffer);
frame[0] = address; frame[1] = FunctionCode.WriteMultipleRegisters; view.setUint16(2, start); view.setUint16(4, values.length); frame[6] = count; values.forEach((value, index) => view.setUint16(7 + index * 2, value)); writeCrc(frame);
return frame;}
/** Local to keep `crc.append`'s name from colliding with the array method of the same idea */function writeCrc(frame: Uint8Array): void { const offset = frame.length - 2; const crc = crc16(frame, 0, offset); frame[offset] = crc & 0xff; frame[offset + 1] = crc >> 8;}
/** Re-exported so callers checking a frame do not need to reach into `crc.ts` for the byte order */export { readCrc };