Modbus TCP in Node.js: client e server con il modulo net
Abbiamo costruito un'implementazione di Modbus TCP che sfrutta il modello a eventi di Node.js invece di combatterlo. Un parser di frame continuo mantiene i confini dei messaggi indipendentemente da come TCP frammenta i dati. L'associazione tramite transaction identifier rende innocue le risposte tardive e permette il pipelining dove il dispositivo lo consente.
Node.js è diventato una scelta frequente per i gateway IoT e per i servizi che fanno da ponte tra il mondo industriale e quello web. Un processo Node legge i dati dai dispositivi sul campo, li pubblica su MQTT o li espone tramite un'API REST o WebSocket, e il tutto senza thread né lock grazie al ciclo degli eventi. Sul campo, nella maggior parte dei casi, il protocollo da parlare è Modbus.
In questo articolo implementiamo Modbus TCP da zero in Node.js usando soltanto i moduli integrati: net per i socket e Buffer per la codifica binaria. Il modello a eventi di Node ci porterà a un'architettura diversa da quella di un client sincrono: un parser di frame continuo che associa le risposte alle richieste tramite il transaction identifier, con il supporto opzionale per più richieste in volo sulla stessa connessione. Completeremo il lavoro con un server, le conversioni tra registri e valori numerici, il CRC di Modbus RTU e una suite di test con node:test.
Il modello dati di Modbus
Ogni dispositivo Modbus espone quattro tabelle, ciascuna con indirizzi da 0 a 65535:
| Tabella | Elemento | Accesso | Esempi |
|---|---|---|---|
| Coil | bit | lettura e scrittura | relè, comandi |
| Discrete input | bit | sola lettura | ingressi digitali, allarmi |
| Holding register | 16 bit | lettura e scrittura | setpoint, configurazione |
| Input register | 16 bit | sola lettura | misure |
Le schede tecniche usano spesso la numerazione storica, in cui 40001 indica il primo holding register e 30001 il primo input register. Questi numeri partono da 1, ma l'indirizzo trasmesso nel pacchetto parte da 0: il registro 40001 corrisponde all'indirizzo 0. Quando i valori letti appaiono sfasati di una posizione rispetto al manuale, la causa è quasi sempre questa.
Il formato dei messaggi
Il contenuto di un messaggio Modbus è la PDU: un byte con il codice funzione seguito dai parametri, per un massimo di 253 byte. Su TCP la PDU viene preceduta da un'intestazione MBAP di sette byte, con tutti i campi in big-endian:
- transaction identifier (2 byte): scelto dal client e ricopiato dal server nella risposta;
- protocol identifier (2 byte): sempre 0;
- length (2 byte): byte successivi, cioè unit identifier più PDU;
- unit identifier (1 byte): indirizzo del dispositivo finale, rilevante dietro un gateway seriale.
Con i metodi di Buffer la costruzione di una richiesta di lettura di dieci holding register dall'indirizzo 0 è immediata:
const frame = Buffer.alloc(12);
frame.writeUInt16BE(1, 0); // transaction identifier
frame.writeUInt16BE(0, 2); // protocol identifier
frame.writeUInt16BE(6, 4); // length
frame.writeUInt8(1, 6); // unit identifier
frame.writeUInt8(0x03, 7); // codice funzione
frame.writeUInt16BE(0, 8); // indirizzo iniziale
frame.writeUInt16BE(10, 10); // quantita'
console.log(frame); // <Buffer 00 01 00 00 00 06 01 03 00 00 00 0a>
Il campo transaction identifier è ciò che rende possibile l'architettura di questo articolo: poiché il server lo ricopia nella risposta, il client può inviare più richieste e riconoscere a quale si riferisce ciascuna risposta. Se una richiesta non può essere soddisfatta, il server risponde con il codice funzione con il bit più alto acceso, per esempio 0x83, seguito da un codice di eccezione: 0x01 funzione non supportata, 0x02 indirizzo non valido, 0x03 valore non valido, 0x04 guasto del dispositivo.
Struttura del progetto
modbus-node/
├── package.json
├── src/
│ ├── index.js esportazioni pubbliche
│ ├── errors.js gerarchia degli errori
│ ├── pdu.js codifica e decodifica delle PDU
│ ├── mbap.js intestazione MBAP e parser dei frame
│ ├── client.js client TCP
│ ├── server.js memoria dati e server TCP
│ ├── codec.js conversioni tra registri e tipi numerici
│ └── crc.js CRC16 per Modbus RTU
├── test/modbus.test.js
└── examples/demo.js
Usiamo i moduli ES e nessuna dipendenza esterna. Il test runner integrato di Node è stabile da tempo e copre tutto ciò che ci serve.
{
"name": "modbus-node",
"version": "0.1.0",
"type": "module",
"exports": "./src/index.js",
"engines": {
"node": ">=22"
},
"scripts": {
"test": "node --test"
}
}
Gli errori
Definiamo una gerarchia di errori che permetta al chiamante di reagire in modo diverso a seconda del problema. ModbusException rappresenta il caso in cui il dispositivo ha capito la richiesta e l'ha rifiutata: la connessione resta valida. TimeoutError e ClientClosedError riguardano invece il trasporto.
// Classe base per tutti gli errori della libreria
export class ModbusError extends Error {
constructor(message) {
super(message);
this.name = new.target.name;
}
}
// Risposta malformata o incoerente con la richiesta
export class ProtocolError extends ModbusError {}
// Nessuna risposta entro il timeout
export class TimeoutError extends ModbusError {}
// Connessione chiusa prima della risposta
export class ClientClosedError extends ModbusError {
constructor() {
super('client closed');
}
}
const EXCEPTION_NAMES = {
1: 'illegal function',
2: 'illegal data address',
3: 'illegal data value',
4: 'server device failure',
};
// Il dispositivo ha risposto con un'eccezione Modbus
export class ModbusException extends ModbusError {
constructor(functionCode, exceptionCode) {
const name = EXCEPTION_NAMES[exceptionCode] ?? 'unknown';
const hex = (n) => `0x${n.toString(16).toUpperCase().padStart(2, '0')}`;
super(`exception ${hex(exceptionCode)} (${name}) for function ${hex(functionCode)}`);
this.functionCode = functionCode;
this.exceptionCode = exceptionCode;
}
}
L'uso di new.target.name nel costruttore base assegna automaticamente a ogni sottoclasse il proprio nome, che compare nelle tracce dello stack e nei log senza doverlo ripetere in ogni classe.
Codifica e decodifica delle PDU
Il modulo pdu.js contiene funzioni pure che lavorano solo su Buffer. I limiti di quantità derivano dalla dimensione massima della PDU: una risposta con 125 registri occupa 252 byte, l'ultima misura compatibile con i 253 disponibili. Per i coil la specifica impone i valori 0xFF00 per acceso e 0x0000 per spento.
import { ModbusException, ProtocolError } from './errors.js';
export const FunctionCode = Object.freeze({
READ_COILS: 0x01,
READ_DISCRETE_INPUTS: 0x02,
READ_HOLDING_REGISTERS: 0x03,
READ_INPUT_REGISTERS: 0x04,
WRITE_SINGLE_COIL: 0x05,
WRITE_SINGLE_REGISTER: 0x06,
WRITE_MULTIPLE_REGISTERS: 0x10,
});
export const ExceptionCode = Object.freeze({
ILLEGAL_FUNCTION: 0x01,
ILLEGAL_DATA_ADDRESS: 0x02,
ILLEGAL_DATA_VALUE: 0x03,
SERVER_DEVICE_FAILURE: 0x04,
});
// Quantita' massime per singola richiesta
export const MAX_READ_BITS = 2000;
export const MAX_READ_REGISTERS = 125;
export const MAX_WRITE_REGISTERS = 123;
export function readRequest(functionCode, address, quantity) {
const pdu = Buffer.alloc(5);
pdu.writeUInt8(functionCode, 0);
pdu.writeUInt16BE(address, 1);
pdu.writeUInt16BE(quantity, 3);
return pdu;
}
export function writeSingleCoilRequest(address, value) {
return readRequest(FunctionCode.WRITE_SINGLE_COIL, address, value ? 0xff00 : 0x0000);
}
export function writeSingleRegisterRequest(address, value) {
return readRequest(FunctionCode.WRITE_SINGLE_REGISTER, address, value);
}
export function writeMultipleRegistersRequest(address, values) {
const pdu = Buffer.alloc(6 + 2 * values.length);
pdu.writeUInt8(FunctionCode.WRITE_MULTIPLE_REGISTERS, 0);
pdu.writeUInt16BE(address, 1);
pdu.writeUInt16BE(values.length, 3);
pdu.writeUInt8(2 * values.length, 5);
values.forEach((value, i) => pdu.writeUInt16BE(value, 6 + 2 * i));
return pdu;
}
A differenza di altri linguaggi, i metodi di scrittura di Buffer sollevano un RangeError quando il valore non entra nel campo: writeUInt16BE(70000) non tronca silenziosamente. È una protezione utile, ma il messaggio generato non spiega quale parametro fosse sbagliato, quindi aggiungeremo comunque una validazione esplicita nel client.
Coil e discrete input viaggiano impacchettati otto per byte, a partire dal bit meno significativo del primo byte:
export function packBits(bits) {
const out = Buffer.alloc(Math.ceil(bits.length / 8));
bits.forEach((bit, i) => {
if (bit) out[i >> 3] |= 1 << (i & 7);
});
return out;
}
export function unpackBits(data, count) {
return Array.from({ length: count }, (_, i) => (data[i >> 3] & (1 << (i & 7))) !== 0);
}
Sul lato delle risposte verifichiamo codice funzione, byte di conteggio e lunghezza. Per le scritture controlliamo l'eco: le funzioni 0x05 e 0x06 rispondono con una copia della richiesta, la 0x10 con i suoi primi cinque byte. In entrambi i casi basta confrontare i primi cinque byte, operazione che Buffer.equals esegue in modo efficiente.
export function checkFunction(functionCode, pdu) {
if (pdu.length === 0) throw new ProtocolError('empty response');
if (pdu[0] === (functionCode | 0x80)) {
if (pdu.length !== 2) throw new ProtocolError('malformed exception response');
throw new ModbusException(functionCode, pdu[1]);
}
if (pdu[0] !== functionCode) {
throw new ProtocolError(`unexpected function 0x${pdu[0].toString(16)}`);
}
}
export function parseBits(functionCode, pdu, quantity) {
checkFunction(functionCode, pdu);
const expected = Math.ceil(quantity / 8);
if (pdu.length !== 2 + expected || pdu[1] !== expected) {
throw new ProtocolError('unexpected byte count');
}
return unpackBits(pdu.subarray(2), quantity);
}
export function parseRegisters(functionCode, pdu, quantity) {
checkFunction(functionCode, pdu);
const expected = 2 * quantity;
if (pdu.length !== 2 + expected || pdu[1] !== expected) {
throw new ProtocolError('unexpected byte count');
}
return Array.from({ length: quantity }, (_, i) => pdu.readUInt16BE(2 + 2 * i));
}
export function parseEcho(request, pdu) {
checkFunction(request[0], pdu);
if (!pdu.equals(request.subarray(0, 5))) {
throw new ProtocolError('write echo mismatch');
}
}
Il parser dei frame
In Node i dati arrivano dal socket sotto forma di eventi data, e un evento non corrisponde a un frame. Un singolo chunk può contenere mezzo frame, un frame esatto o tre frame e un pezzo del quarto: TCP garantisce l'ordine dei byte, non i confini dei messaggi. Serve quindi un parser che accumuli i byte e restituisca i frame completi man mano che diventano disponibili.
Lo stesso parser servirà sia al client sia al server, perché richieste e risposte hanno la stessa intestazione. Un frame è completo quando il buffer contiene i sei byte iniziali, fino al campo length incluso, più i byte indicati da quel campo.
import { ProtocolError } from './errors.js';
export const HEADER_LENGTH = 7;
export function encodeFrame(transactionId, unitId, pdu) {
const frame = Buffer.alloc(HEADER_LENGTH + pdu.length);
frame.writeUInt16BE(transactionId, 0);
frame.writeUInt16BE(0, 2);
frame.writeUInt16BE(pdu.length + 1, 4);
frame.writeUInt8(unitId, 6);
pdu.copy(frame, HEADER_LENGTH);
return frame;
}
// Accumula i chunk ricevuti e restituisce i frame completi
export class FrameReader {
#buffer = Buffer.alloc(0);
push(chunk) {
this.#buffer = this.#buffer.length === 0 ? chunk : Buffer.concat([this.#buffer, chunk]);
const frames = [];
while (this.#buffer.length >= HEADER_LENGTH) {
const protocolId = this.#buffer.readUInt16BE(2);
const length = this.#buffer.readUInt16BE(4);
if (protocolId !== 0 || length < 2 || length > 254) {
throw new ProtocolError('invalid MBAP header');
}
const frameLength = 6 + length;
if (this.#buffer.length < frameLength) break;
frames.push({
transactionId: this.#buffer.readUInt16BE(0),
unitId: this.#buffer[6],
// copia: il buffer originale verra' scartato o riutilizzato
pdu: Buffer.from(this.#buffer.subarray(HEADER_LENGTH, frameLength)),
});
this.#buffer = this.#buffer.subarray(frameLength);
}
return frames;
}
}
Un'intestazione non valida fa sollevare un'eccezione, e chi usa il parser chiuderà la connessione. Dopo un errore di framing non esiste un modo affidabile per capire dove inizi il frame successivo, quindi continuare significherebbe interpretare byte casuali come dati.
Il client
Il client è costruito attorno a tre strutture: una coda di richieste in attesa di essere inviate, una mappa delle richieste in volo indicizzata per transaction identifier e il parser dei frame. Quando arriva una risposta, il client la cerca nella mappa e risolve la promise corrispondente. Una risposta con un identificativo sconosciuto, per esempio quella di una richiesta già scaduta per timeout, viene semplicemente scartata.
Questo modello ha una conseguenza importante. In un client sincrono, dopo un timeout lo stream non è più affidabile, perché la risposta tardiva verrebbe letta come risposta alla richiesta successiva. Qui invece il parser consuma tutti i byte in arrivo e mantiene sempre i confini dei frame, quindi una risposta tardiva viene riconosciuta e ignorata, e la connessione può restare aperta anche dopo un timeout.
Il parametro maxInFlight controlla quante richieste possono essere in volo contemporaneamente. Il valore predefinito è 1, perché molti dispositivi embedded elaborano una richiesta alla volta e si comportano in modo imprevedibile se ne ricevono altre prima di aver risposto. Con gateway e PLC che supportano il pipelining, aumentarlo riduce drasticamente la latenza complessiva di un ciclo di letture.
import net from 'node:net';
import { once } from 'node:events';
import { ClientClosedError, ProtocolError, TimeoutError } from './errors.js';
import { FrameReader, encodeFrame } from './mbap.js';
import {
FunctionCode,
MAX_READ_BITS,
MAX_READ_REGISTERS,
MAX_WRITE_REGISTERS,
parseBits,
parseEcho,
parseRegisters,
readRequest,
writeMultipleRegistersRequest,
writeSingleCoilRequest,
writeSingleRegisterRequest,
} from './pdu.js';
export class ModbusTcpClient {
#socket;
#unitId;
#timeout;
#maxInFlight;
#reader = new FrameReader();
#pending = new Map();
#queue = [];
#transactionId = 0;
#closed = false;
constructor(socket, { unitId = 1, timeout = 2000, maxInFlight = 1 } = {}) {
this.#socket = socket;
this.#unitId = unitId;
this.#timeout = timeout;
this.#maxInFlight = maxInFlight;
// i frame Modbus sono piccoli: disabilitiamo l'algoritmo di Nagle
socket.setNoDelay(true);
socket.on('data', (chunk) => this.#onData(chunk));
socket.on('error', () => {});
socket.on('close', () => this.#onClose());
}
static connect({ host, port = 502, timeout = 2000, ...options } = {}) {
return new Promise((resolve, reject) => {
const socket = net.connect({ host, port });
const timer = setTimeout(() => {
socket.destroy();
reject(new TimeoutError(`connection to ${host}:${port} timed out`));
}, timeout);
const onError = (err) => {
clearTimeout(timer);
reject(err);
};
socket.once('error', onError);
socket.once('connect', () => {
clearTimeout(timer);
socket.off('error', onError);
resolve(new ModbusTcpClient(socket, { timeout, ...options }));
});
});
}
async close() {
if (this.#socket.destroyed) return;
const closed = once(this.#socket, 'close');
// Modbus non prevede una chiusura concordata: non attendiamo il peer
this.#socket.destroy();
await closed;
}
Il metodo close usa destroy invece di end. Con end il client invierebbe la richiesta di chiusura e aspetterebbe che anche il dispositivo chiuda il proprio lato, cosa che un apparato bloccato o un gateway difettoso potrebbe non fare mai, lasciando la promise in sospeso per sempre. Modbus non prevede alcun messaggio di chiusura, quindi non perdiamo nulla chiudendo subito.
La chiamata a setNoDelay(true) merita una spiegazione. Per impostazione predefinita TCP applica l'algoritmo di Nagle, che trattiene i pacchetti piccoli in attesa di accorparli. Per un protocollo a richiesta e risposta con frame di una dozzina di byte, il risultato può essere un ritardo artificiale di decine di millisecondi su ogni transazione.
Il cuore del client sono i metodi privati che gestiscono coda, invio e ricezione. Ogni richiesta inviata ha il proprio timer: allo scadere, la richiesta viene rimossa dalla mappa, la promise viene rifiutata e la coda riparte, liberando il posto per la richiesta successiva.
#request(pdu) {
if (this.#closed) return Promise.reject(new ClientClosedError());
return new Promise((resolve, reject) => {
this.#queue.push({ pdu, resolve, reject });
this.#drain();
});
}
#drain() {
while (!this.#closed && this.#pending.size < this.#maxInFlight && this.#queue.length > 0) {
const job = this.#queue.shift();
this.#transactionId = (this.#transactionId + 1) & 0xffff;
const transactionId = this.#transactionId;
const timer = setTimeout(() => {
this.#pending.delete(transactionId);
job.reject(new TimeoutError(`no response within ${this.#timeout} ms`));
this.#drain();
}, this.#timeout);
this.#pending.set(transactionId, { ...job, timer });
this.#socket.write(encodeFrame(transactionId, this.#unitId, job.pdu));
}
}
#onData(chunk) {
let frames;
try {
frames = this.#reader.push(chunk);
} catch (err) {
// errore di framing: la connessione non e' piu' recuperabile
this.#socket.destroy(err);
return;
}
for (const frame of frames) {
const entry = this.#pending.get(frame.transactionId);
if (!entry) continue; // risposta tardiva a una richiesta gia' scaduta
this.#pending.delete(frame.transactionId);
clearTimeout(entry.timer);
if (frame.unitId !== this.#unitId) {
entry.reject(new ProtocolError(`unexpected unit id ${frame.unitId}`));
} else {
entry.resolve(frame.pdu);
}
}
this.#drain();
}
#onClose() {
this.#closed = true;
for (const entry of this.#pending.values()) {
clearTimeout(entry.timer);
entry.reject(new ClientClosedError());
}
for (const job of this.#queue) job.reject(new ClientClosedError());
this.#pending.clear();
this.#queue = [];
}
Il gestore vuoto dell'evento error non è una dimenticanza. In Node un EventEmitter che emette error senza listener fa terminare il processo. Nel nostro caso ogni errore di rete è sempre seguito dall'evento close, che rifiuta tutte le richieste in sospeso con ClientClosedError: non serve fare altro, ma il listener deve esistere.
L'API pubblica valida i parametri e poi delega a #request. Ogni metodo è async, quindi gli errori di validazione arrivano al chiamante come promise rifiutate, coerentemente con gli altri errori.
static #check(address, quantity, max) {
if (!Number.isInteger(address) || address < 0 || address > 0xffff) {
throw new RangeError(`address ${address} out of range 0-65535`);
}
if (!Number.isInteger(quantity) || quantity < 1 || quantity > max) {
throw new RangeError(`quantity ${quantity} out of range 1-${max}`);
}
}
static #checkValues(values) {
for (const value of values) {
if (!Number.isInteger(value) || value < 0 || value > 0xffff) {
throw new RangeError(`register value ${value} out of range 0-65535`);
}
}
}
async #readBits(functionCode, address, quantity) {
ModbusTcpClient.#check(address, quantity, MAX_READ_BITS);
const pdu = await this.#request(readRequest(functionCode, address, quantity));
return parseBits(functionCode, pdu, quantity);
}
async #readRegisters(functionCode, address, quantity) {
ModbusTcpClient.#check(address, quantity, MAX_READ_REGISTERS);
const pdu = await this.#request(readRequest(functionCode, address, quantity));
return parseRegisters(functionCode, pdu, quantity);
}
readCoils(address, quantity) {
return this.#readBits(FunctionCode.READ_COILS, address, quantity);
}
readDiscreteInputs(address, quantity) {
return this.#readBits(FunctionCode.READ_DISCRETE_INPUTS, address, quantity);
}
readHoldingRegisters(address, quantity) {
return this.#readRegisters(FunctionCode.READ_HOLDING_REGISTERS, address, quantity);
}
readInputRegisters(address, quantity) {
return this.#readRegisters(FunctionCode.READ_INPUT_REGISTERS, address, quantity);
}
async writeSingleCoil(address, value) {
ModbusTcpClient.#check(address, 1, 1);
const request = writeSingleCoilRequest(address, value);
parseEcho(request, await this.#request(request));
}
async writeSingleRegister(address, value) {
ModbusTcpClient.#check(address, 1, 1);
ModbusTcpClient.#checkValues([value]);
const request = writeSingleRegisterRequest(address, value);
parseEcho(request, await this.#request(request));
}
async writeMultipleRegisters(address, values) {
ModbusTcpClient.#check(address, values.length, MAX_WRITE_REGISTERS);
ModbusTcpClient.#checkValues(values);
const request = writeMultipleRegistersRequest(address, values);
parseEcho(request, await this.#request(request));
}
}
La memoria dati e il server
La classe DataStore contiene le quattro tabelle e trasforma una PDU di richiesta in una PDU di risposta. Per i registri usiamo Uint16Array: occupa due byte per elemento invece degli otto di un numero JavaScript, e garantisce per costruzione che ogni valore resti nell'intervallo di un registro. Come in tutto il codice Node, non servono lock: handle è sincrono e nessun altro codice può essere eseguito mentre è in corso.
import net from 'node:net';
import { FrameReader, encodeFrame } from './mbap.js';
import {
ExceptionCode,
FunctionCode,
MAX_READ_BITS,
MAX_READ_REGISTERS,
MAX_WRITE_REGISTERS,
packBits,
} from './pdu.js';
function exception(functionCode, code) {
return Buffer.from([functionCode | 0x80, code]);
}
export class DataStore {
constructor(size) {
this.coils = new Array(size).fill(false);
this.discreteInputs = new Array(size).fill(false);
this.holdingRegisters = new Uint16Array(size);
this.inputRegisters = new Uint16Array(size);
}
// Elabora una PDU di richiesta e restituisce la PDU di risposta
handle(pdu) {
if (pdu.length === 0) return exception(0, ExceptionCode.ILLEGAL_FUNCTION);
const fn = pdu[0];
switch (fn) {
case FunctionCode.READ_COILS:
case FunctionCode.READ_DISCRETE_INPUTS:
return this.#readBits(fn, pdu);
case FunctionCode.READ_HOLDING_REGISTERS:
case FunctionCode.READ_INPUT_REGISTERS:
return this.#readRegisters(fn, pdu);
case FunctionCode.WRITE_SINGLE_COIL:
return this.#writeSingleCoil(pdu);
case FunctionCode.WRITE_SINGLE_REGISTER:
return this.#writeSingleRegister(pdu);
case FunctionCode.WRITE_MULTIPLE_REGISTERS:
return this.#writeMultipleRegisters(pdu);
default:
return exception(fn, ExceptionCode.ILLEGAL_FUNCTION);
}
}
#readBits(fn, pdu) {
if (pdu.length !== 5) return exception(fn, ExceptionCode.ILLEGAL_DATA_VALUE);
const address = pdu.readUInt16BE(1);
const quantity = pdu.readUInt16BE(3);
if (quantity < 1 || quantity > MAX_READ_BITS) {
return exception(fn, ExceptionCode.ILLEGAL_DATA_VALUE);
}
const table = fn === FunctionCode.READ_COILS ? this.coils : this.discreteInputs;
if (address + quantity > table.length) {
return exception(fn, ExceptionCode.ILLEGAL_DATA_ADDRESS);
}
const data = packBits(table.slice(address, address + quantity));
return Buffer.concat([Buffer.from([fn, data.length]), data]);
}
#readRegisters(fn, pdu) {
if (pdu.length !== 5) return exception(fn, ExceptionCode.ILLEGAL_DATA_VALUE);
const address = pdu.readUInt16BE(1);
const quantity = pdu.readUInt16BE(3);
if (quantity < 1 || quantity > MAX_READ_REGISTERS) {
return exception(fn, ExceptionCode.ILLEGAL_DATA_VALUE);
}
const table =
fn === FunctionCode.READ_HOLDING_REGISTERS ? this.holdingRegisters : this.inputRegisters;
if (address + quantity > table.length) {
return exception(fn, ExceptionCode.ILLEGAL_DATA_ADDRESS);
}
const response = Buffer.alloc(2 + 2 * quantity);
response[0] = fn;
response[1] = 2 * quantity;
for (let i = 0; i < quantity; i++) {
response.writeUInt16BE(table[address + i], 2 + 2 * i);
}
return response;
}
#writeSingleCoil(pdu) {
const fn = FunctionCode.WRITE_SINGLE_COIL;
if (pdu.length !== 5) return exception(fn, ExceptionCode.ILLEGAL_DATA_VALUE);
const address = pdu.readUInt16BE(1);
const value = pdu.readUInt16BE(3);
if (value !== 0xff00 && value !== 0x0000) {
return exception(fn, ExceptionCode.ILLEGAL_DATA_VALUE);
}
if (address >= this.coils.length) return exception(fn, ExceptionCode.ILLEGAL_DATA_ADDRESS);
this.coils[address] = value === 0xff00;
return Buffer.from(pdu);
}
#writeSingleRegister(pdu) {
const fn = FunctionCode.WRITE_SINGLE_REGISTER;
if (pdu.length !== 5) return exception(fn, ExceptionCode.ILLEGAL_DATA_VALUE);
const address = pdu.readUInt16BE(1);
if (address >= this.holdingRegisters.length) {
return exception(fn, ExceptionCode.ILLEGAL_DATA_ADDRESS);
}
this.holdingRegisters[address] = pdu.readUInt16BE(3);
return Buffer.from(pdu);
}
#writeMultipleRegisters(pdu) {
const fn = FunctionCode.WRITE_MULTIPLE_REGISTERS;
if (pdu.length < 6) return exception(fn, ExceptionCode.ILLEGAL_DATA_VALUE);
const address = pdu.readUInt16BE(1);
const quantity = pdu.readUInt16BE(3);
const byteCount = pdu[5];
if (
quantity < 1 ||
quantity > MAX_WRITE_REGISTERS ||
byteCount !== 2 * quantity ||
pdu.length !== 6 + byteCount
) {
return exception(fn, ExceptionCode.ILLEGAL_DATA_VALUE);
}
if (address + quantity > this.holdingRegisters.length) {
return exception(fn, ExceptionCode.ILLEGAL_DATA_ADDRESS);
}
for (let i = 0; i < quantity; i++) {
this.holdingRegisters[address + i] = pdu.readUInt16BE(6 + 2 * i);
}
return Buffer.from(pdu.subarray(0, 5));
}
}
L'ordine dei controlli segue la specifica: prima il codice funzione, poi la forma della richiesta e le quantità (eccezione 0x03), infine gli indirizzi (eccezione 0x02).
Il server TCP crea un FrameReader per ogni connessione e risponde a ogni frame completo ricopiando transaction identifier e unit identifier. Il metodo setTimeout del socket conta il tempo di inattività, che è esattamente ciò che serve per chiudere le connessioni abbandonate. Molti dispositivi reali accettano pochissime connessioni contemporanee, e un client che si riavvia senza chiudere il socket può occupare un posto per ore.
export class ModbusTcpServer {
#server;
#sockets = new Set();
constructor(store, { unitId = 0, idleTimeout = 0 } = {}) {
this.store = store;
// 0 accetta qualunque unit identifier
this.unitId = unitId;
// millisecondi di inattivita' prima della chiusura, 0 per disabilitare
this.idleTimeout = idleTimeout;
this.#server = net.createServer((socket) => this.#onConnection(socket));
}
listen(port = 502, host = '0.0.0.0') {
return new Promise((resolve, reject) => {
this.#server.once('error', reject);
this.#server.listen(port, host, () => {
this.#server.off('error', reject);
resolve(this.#server.address());
});
});
}
close() {
for (const socket of this.#sockets) socket.destroy();
return new Promise((resolve) => this.#server.close(() => resolve()));
}
#onConnection(socket) {
socket.setNoDelay(true);
this.#sockets.add(socket);
socket.on('close', () => this.#sockets.delete(socket));
socket.on('error', () => {});
if (this.idleTimeout > 0) {
socket.setTimeout(this.idleTimeout, () => socket.destroy());
}
const reader = new FrameReader();
socket.on('data', (chunk) => {
let frames;
try {
frames = reader.push(chunk);
} catch {
socket.destroy();
return;
}
for (const frame of frames) {
if (this.unitId !== 0 && frame.unitId !== this.unitId) {
// un dispositivo reale ignora le richieste destinate ad altri
continue;
}
const response = this.store.handle(frame.pdu);
socket.write(encodeFrame(frame.transactionId, frame.unitId, response));
}
});
}
}
Il metodo close distrugge esplicitamente i socket aperti prima di chiudere il server: server.close smette di accettare nuove connessioni ma attende che quelle esistenti si chiudano da sole, e con client che mantengono la connessione aperta per ore non succederebbe mai.
Dai registri ai valori
Un registro è un intero senza segno a 16 bit, e il significato lo decide il produttore. Le codifiche più frequenti sono l'intero con segno a 16 bit, spesso scalato, l'intero a 32 bit su due registri e il float32 IEEE 754 su due registri. Per i valori a 32 bit l'ordine dei registri non è stabilito dalla specifica: alcuni dispositivi mettono per prima la parola alta (ordine ABCD), altri la parola bassa (CDAB).
export const WordOrder = Object.freeze({
HIGH_WORD_FIRST: 'ABCD',
LOW_WORD_FIRST: 'CDAB',
});
// Interpreta un registro come intero con segno in complemento a due
export function int16FromRegister(value) {
return (value << 16) >> 16;
}
function ordered(registers, order) {
if (registers.length !== 2) throw new RangeError('exactly two registers are required');
const [first, second] = registers;
return order === WordOrder.LOW_WORD_FIRST ? [second, first] : [first, second];
}
export function uint32FromRegisters(registers, order) {
const [high, low] = ordered(registers, order);
return ((high << 16) | low) >>> 0;
}
export function registersFromUint32(value, order) {
const high = (value >>> 16) & 0xffff;
const low = value & 0xffff;
return order === WordOrder.LOW_WORD_FIRST ? [low, high] : [high, low];
}
export function float32FromRegisters(registers, order) {
const [high, low] = ordered(registers, order);
const buffer = Buffer.alloc(4);
buffer.writeUInt16BE(high, 0);
buffer.writeUInt16BE(low, 2);
return buffer.readFloatBE(0);
}
export function registersFromFloat32(value, order) {
const buffer = Buffer.alloc(4);
buffer.writeFloatBE(value, 0);
return registersFromUint32(buffer.readUInt32BE(0), order);
}
Le operazioni bit a bit di JavaScript lavorano su interi con segno a 32 bit, e questo spiega due dettagli. In int16FromRegister lo spostamento a sinistra di 16 bit porta il bit di segno del registro nella posizione del bit di segno a 32 bit, e lo spostamento aritmetico a destra lo propaga: 0xFF38 diventa -200. In uint32FromRegisters, invece, lo spostamento logico >>> 0 finale riconverte il risultato in un intero senza segno. Senza di esso, un valore con il bit più alto acceso risulterebbe negativo.
Quando la documentazione non chiarisce l'ordine delle parole, conviene leggere una grandezza nota, come la tensione di rete o la frequenza a 50 Hz, e provare entrambe le interpretazioni: quella sbagliata produce valori palesemente assurdi.
Il CRC di Modbus RTU
Molti dispositivi parlano solo Modbus RTU su RS-485, e alcuni gateway inoltrano su TCP i frame RTU senza modificarli. In questi casi serve il CRC-16 di Modbus: polinomio riflesso 0xA001, valore iniziale 0xFFFF, trasmesso con il byte meno significativo per primo.
export function crc16(data) {
let crc = 0xffff;
for (const byte of data) {
crc ^= byte;
for (let i = 0; i < 8; i++) {
crc = crc & 1 ? (crc >>> 1) ^ 0xa001 : crc >>> 1;
}
}
return crc;
}
// Aggiunge il CRC al frame, byte meno significativo per primo
export function appendCrc(frame) {
const out = Buffer.alloc(frame.length + 2);
frame.copy(out);
out.writeUInt16LE(crc16(frame), frame.length);
return out;
}
La richiesta RTU di dieci holding register all'unità 1 diventa 01 03 00 00 00 0A C5 CD. Per leggere davvero da una porta seriale in Node serve il pacchetto serialport, ma la costruzione e la verifica dei frame restano quelle viste qui.
Il file index.js raccoglie le esportazioni pubbliche:
export * from './errors.js';
export { FunctionCode, ExceptionCode } from './pdu.js';
export { ModbusTcpClient } from './client.js';
export { DataStore, ModbusTcpServer } from './server.js';
export * from './codec.js';
export { crc16, appendCrc } from './crc.js';
I test
Il test runner integrato, importato da node:test, supporta test asincroni e hook di setup e teardown. Ogni test avvia un server su una porta libera, indicando la porta 0, e lo chiude alla fine con t.after.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import net from 'node:net';
import {
ClientClosedError,
DataStore,
ExceptionCode,
ModbusException,
ModbusTcpClient,
ModbusTcpServer,
TimeoutError,
WordOrder,
appendCrc,
float32FromRegisters,
int16FromRegister,
registersFromFloat32,
} from '../src/index.js';
async function setup(t, options = {}) {
const store = new DataStore(100);
const server = new ModbusTcpServer(store);
const { port } = await server.listen(0, '127.0.0.1');
const client = await ModbusTcpClient.connect({ host: '127.0.0.1', port, timeout: 1000, ...options });
t.after(async () => {
await client.close();
await server.close();
});
return { store, client };
}
test('registers round trip', async (t) => {
const { client } = await setup(t);
await client.writeMultipleRegisters(10, [1, 2, 0xbeef]);
await client.writeSingleRegister(13, 42);
assert.deepEqual(await client.readHoldingRegisters(10, 4), [1, 2, 0xbeef, 42]);
});
test('coils and inputs', async (t) => {
const { store, client } = await setup(t);
store.discreteInputs[9] = true;
store.inputRegisters[0] = 0xff38;
await client.writeSingleCoil(3, true);
const coils = await client.readCoils(0, 10);
assert.deepEqual(coils, Array.from({ length: 10 }, (_, i) => i === 3));
const inputs = await client.readDiscreteInputs(0, 10);
assert.equal(inputs[9], true);
assert.equal(inputs[8], false);
const [raw] = await client.readInputRegisters(0, 1);
assert.equal(int16FromRegister(raw), -200);
});
test('illegal address keeps the connection usable', async (t) => {
const { client } = await setup(t);
await assert.rejects(client.readHoldingRegisters(95, 10), (err) => {
assert.ok(err instanceof ModbusException);
assert.equal(err.exceptionCode, ExceptionCode.ILLEGAL_DATA_ADDRESS);
return true;
});
assert.deepEqual(await client.readHoldingRegisters(0, 1), [0]);
});
test('client side validation', async (t) => {
const { client } = await setup(t);
await assert.rejects(client.readHoldingRegisters(0, 126), RangeError);
await assert.rejects(client.writeSingleRegister(0, 70000), RangeError);
});
test('pipelined requests resolve in order', async (t) => {
const { store, client } = await setup(t, { maxInFlight: 8 });
store.holdingRegisters.set([10, 20, 30, 40, 50]);
const results = await Promise.all([0, 1, 2, 3, 4].map((i) => client.readHoldingRegisters(i, 1)));
assert.deepEqual(results.flat(), [10, 20, 30, 40, 50]);
});
test('timeout rejects and close rejects pending requests', async (t) => {
// un server che consuma le richieste ma non risponde mai
const silent = net.createServer((socket) => socket.resume());
await new Promise((resolve) => silent.listen(0, '127.0.0.1', resolve));
const { port } = silent.address();
const client = await ModbusTcpClient.connect({ host: '127.0.0.1', port, timeout: 100 });
t.after(() => new Promise((resolve) => silent.close(resolve)));
await assert.rejects(client.readCoils(0, 1), TimeoutError);
const pending = client.readCoils(0, 1);
await client.close();
await assert.rejects(pending, ClientClosedError);
await assert.rejects(client.readCoils(0, 1), ClientClosedError);
});
test('illegal function', () => {
const response = new DataStore(10).handle(Buffer.from([0x2b, 0x0e]));
assert.deepEqual([...response], [0xab, 0x01]);
});
test('float word order', () => {
for (const order of Object.values(WordOrder)) {
assert.equal(float32FromRegisters(registersFromFloat32(230.5, order), order), 230.5);
}
assert.deepEqual(registersFromFloat32(1.0, WordOrder.HIGH_WORD_FIRST), [0x3f80, 0x0000]);
assert.deepEqual(registersFromFloat32(1.0, WordOrder.LOW_WORD_FIRST), [0x0000, 0x3f80]);
});
test('crc16', () => {
const frame = appendCrc(Buffer.from([0x01, 0x03, 0x00, 0x00, 0x00, 0x0a]));
assert.deepEqual([...frame.subarray(-2)], [0xc5, 0xcd]);
});
Il test sul pipelining invia cinque richieste senza attendere le risposte e verifica che ognuna riceva il proprio valore, il che conferma che l'associazione tramite transaction identifier funziona. Il test sul timeout verifica anche che la chiusura del client rifiuti le richieste in sospeso invece di lasciarle appese per sempre, un errore subdolo che nei servizi di lunga durata si manifesta come una crescita lenta e costante della memoria. Si eseguono i test con:
npm test
Un gateway di esempio
Il programma dimostrativo simula un misuratore di energia, con la tensione come float32 negli input register 0 e 1, e un client che la legge ogni secondo. Per mostrare la gestione degli errori in un ciclo di polling reale, il client distingue le eccezioni del dispositivo dai problemi di trasporto, e in caso di disconnessione si riconnette con un backoff esponenziale.
import { setTimeout as sleep } from 'node:timers/promises';
import {
ClientClosedError,
DataStore,
ModbusException,
ModbusTcpClient,
ModbusTcpServer,
WordOrder,
float32FromRegisters,
registersFromFloat32,
} from '../src/index.js';
const store = new DataStore(10);
const server = new ModbusTcpServer(store, { idleTimeout: 60_000 });
await server.listen(1502, '127.0.0.1');
// simulazione della misura di tensione
function updateVoltage() {
const voltage = 228 + Math.random() * 4;
store.inputRegisters.set(registersFromFloat32(voltage, WordOrder.HIGH_WORD_FIRST), 0);
}
updateVoltage();
const simulator = setInterval(updateVoltage, 1000);
async function poll(samples) {
let client = null;
let backoff = 500;
while (samples > 0) {
try {
client ??= await ModbusTcpClient.connect({ host: '127.0.0.1', port: 1502 });
const registers = await client.readInputRegisters(0, 2);
const voltage = float32FromRegisters(registers, WordOrder.HIGH_WORD_FIRST);
console.log(`voltage: ${voltage.toFixed(2)} V`);
samples--;
backoff = 500;
await sleep(1000);
} catch (err) {
if (err instanceof ModbusException) {
console.error(`device error: ${err.message}`);
await sleep(1000);
continue;
}
// errore di trasporto: chiudiamo e riproviamo con backoff esponenziale
console.error(`transport error: ${err.message}, retrying in ${backoff} ms`);
if (client && !(err instanceof ClientClosedError)) await client.close();
client = null;
await sleep(backoff);
backoff = Math.min(backoff * 2, 30_000);
}
}
await client?.close();
}
await poll(5);
clearInterval(simulator);
await server.close();
Il metodo set di Uint16Array copia entrambi i registri in un'unica operazione sincrona, quindi nessun client può leggere una metà della tensione vecchia e una metà di quella nuova. Si avvia con node examples/demo.js. La porta 1502 evita di dover eseguire Node con privilegi elevati, necessari su Linux per la porta standard 502.
Considerazioni per la produzione
- Pipelining con prudenza: aumentare
maxInFlightè vantaggioso solo con dispositivi che lo supportano esplicitamente. Molti gateway seriali, anche se accettano più richieste, le serializzano comunque sulla linea RS-485, e il guadagno è nullo. - Letture raggruppate: ogni transazione costa almeno un round trip. Leggere blocchi contigui di registri con una sola richiesta è molto più efficiente che interrogarli uno per uno.
- Rispetto dei dispositivi: un polling troppo frequente può saturare microcontrollori con poche risorse e farli smettere di rispondere.
- Sicurezza: Modbus TCP non prevede autenticazione né cifratura. Chiunque raggiunga la porta 502 può scrivere registri. I dispositivi vanno isolati in una rete dedicata, con l'accesso filtrato dal firewall. Modbus/TCP Security aggiunge TLS sulla porta 802, ma pochi dispositivi lo supportano.
- Funzioni aggiuntive: la scrittura di più coil (0x0F), la scrittura con maschera (0x16) e la lettura e scrittura combinata (0x17) si aggiungono seguendo lo stesso schema.
Nell'ecosistema npm le librerie più usate sono modbus-serial, che copre TCP e RTU con un'API a promise, e jsmodbus. Avere scritto un'implementazione da zero resta il modo migliore per capire cosa succede quando qualcosa non funziona: con una cattura di Wireshark e la conoscenza dell'intestazione MBAP si capisce in pochi secondi se il problema è nel dispositivo, nella rete o nel codice.
Conclusione
Abbiamo costruito un'implementazione di Modbus TCP che sfrutta il modello a eventi di Node.js invece di combatterlo. Un parser di frame continuo mantiene i confini dei messaggi indipendentemente da come TCP frammenta i dati. L'associazione tramite transaction identifier rende innocue le risposte tardive e permette il pipelining dove il dispositivo lo consente. Il server gestisce un numero qualunque di connessioni senza thread né lock. Con Buffer e Uint16Array la codifica binaria è precisa e sicura, e il test runner integrato ci ha permesso di verificare tutto senza aggiungere una sola dipendenza.
Commenti