Interagire con i sistemi SCADA in Node.js
Un sistema SCADA (Supervisory Control And Data Acquisition) è l'insieme di hardware e software che permette di supervisionare e comandare un processo fisico: un impianto di depurazione, una linea di imbottigliamento, una rete di distribuzione elettrica. Alla base ci sono i dispositivi di campo, come PLC e RTU, che leggono sensori e pilotano attuatori; sopra di essi si collocano i server SCADA, le HMI usate dagli operatori e, sempre più spesso, applicazioni software che raccolgono i dati per portarli verso dashboard web, database storici o sistemi aziendali.
Questo articolo si concentra proprio su quest'ultimo livello. Costruiremo in Node.js un piccolo gateway che legge dati da un PLC tramite Modbus TCP, si abbona alle variabili esposte da un server OPC UA e inoltra tutto a client web tramite WebSocket, con un canale di scrittura protetto da controlli espliciti. Node.js è una scelta sensata per questo ruolo: il modello a eventi gestisce bene molte connessioni contemporanee e l'ecosistema offre librerie mature per entrambi i protocolli.
I due protocolli che incontrerete più spesso
Nel mondo industriale convivono decine di protocolli, ma due coprono la maggior parte dei casi in cui un software esterno deve dialogare con un impianto.
Modbus TCP è il veterano. Nato negli anni Settanta su linea seriale, nella variante TCP incapsula le stesse richieste in un frame con un'intestazione chiamata MBAP. Il modello dei dati è elementare: il dispositivo espone quattro tabelle di variabili, cioè coil (bit leggibili e scrivibili), discrete input (bit in sola lettura), holding register (registri a 16 bit leggibili e scrivibili) e input register (registri a 16 bit in sola lettura). Non esistono nomi, tipi o unità di misura: sapere che il registro 100 contiene la temperatura del serbatoio espressa in decimi di grado è un'informazione che sta nella documentazione del PLC, non nel protocollo.
OPC UA è lo standard moderno (IEC 62541). Il server espone uno spazio degli indirizzi fatto di nodi con identificatori, tipi di dato, timestamp e codici di qualità. Il client può leggere e scrivere, ma soprattutto può creare sottoscrizioni: invece di interrogare il server a intervalli regolari, chiede di essere avvisato quando un valore cambia. OPC UA prevede inoltre canali cifrati, certificati e autenticazione degli utenti, aspetti del tutto assenti in Modbus.
Preparare il progetto
Useremo Node.js 22 o successivo con i moduli ES. Le dipendenze sono tre: modbus-serial per Modbus TCP, node-opcua per OPC UA e ws per il server WebSocket.
mkdir scada-gateway && cd scada-gateway
npm init -y
npm pkg set type=module
npm install modbus-serial node-opcua ws
Per sviluppare senza un impianto reale servono due simulatori: un server Modbus TCP (ne esistono molti, sia grafici sia a riga di comando, e anche la libreria Python pymodbus ne include uno) e un server OPC UA di prova, come quello di esempio distribuito con open62541 o il server demo di Eclipse Milo. L'importante è non sviluppare mai contro un impianto in produzione: una scrittura sbagliata su un holding register può avviare un motore.
Descrivere le variabili come dati
Il primo errore da evitare è disseminare indirizzi di registro nel codice. Ogni variabile di processo, che nel gergo SCADA si chiama tag, va descritta in un file di configurazione con tutto ciò che serve per interpretarla: tabella, indirizzo, tipo, ordine delle word, fattore di scala, unità di misura e, se scrivibile, i limiti ammessi.
// config.js
export const config = {
modbus: {
host: process.env.MODBUS_HOST ?? "127.0.0.1",
port: Number(process.env.MODBUS_PORT ?? 502),
unitId: 1,
timeoutMs: 1500,
pollIntervalMs: 1000,
},
opcua: {
endpointUrl: process.env.OPCUA_URL ?? "opc.tcp://127.0.0.1:4840",
nodes: [
{ name: "line1.speed", nodeId: "ns=2;s=Line1.Speed" },
{ name: "line1.state", nodeId: "ns=2;s=Line1.State" },
],
},
gateway: { port: Number(process.env.WS_PORT ?? 8080) },
tags: [
// Temperatura in decimi di grado su un singolo holding register
{ name: "tank1.temperature", table: "holding", address: 100, type: "int16", scale: 0.1, unit: "°C" },
// Pressione come float a 32 bit distribuito su due registri
{ name: "tank1.pressure", table: "holding", address: 102, type: "float32", wordOrder: "big", unit: "bar" },
// Contatore pezzi a 32 bit senza segno
{ name: "line1.pieces", table: "input", address: 10, type: "uint32", wordOrder: "little" },
// Setpoint scrivibile con limiti di sicurezza
{
name: "tank1.setpoint",
table: "holding",
address: 110,
type: "int16",
scale: 0.1,
unit: "°C",
writable: true,
min: 5,
max: 85,
},
],
};
Notate l'indirizzo: in questa configurazione è sempre l'offset a base zero usato sul filo. Molta documentazione industriale usa invece la notazione storica a cinque o sei cifre, in cui 40101 indica l'holding register 100. Il prefisso 4 identifica la tabella e la numerazione parte da 1. Convertire in un solo punto, la configurazione, evita una delle cause più frequenti di letture sfalsate di un registro.
Decodificare i registri
Modbus trasporta solo parole di 16 bit in ordine big-endian. Tutto ciò che è più grande, come un intero a 32 bit o un float IEEE 754, viene spezzato su due registri consecutivi, e qui iniziano i problemi: il protocollo non stabilisce in che ordine vadano le due word. Alcuni costruttori mettono prima la word alta, altri la bassa. Per questo ogni tag a 32 bit porta con sé il campo wordOrder.
// decoder.js
const REGISTER_COUNT = { int16: 1, uint16: 1, int32: 2, uint32: 2, float32: 2 };
export function registerCount(type) {
const count = REGISTER_COUNT[type];
if (!count) throw new Error(`Tipo non supportato: ${type}`);
return count;
}
export function decodeRegisters(registers, type, wordOrder = "big") {
// Con wordOrder "little" la word meno significativa arriva per prima
const words = wordOrder === "little" ? [...registers].reverse() : registers;
const buffer = Buffer.alloc(words.length * 2);
words.forEach((word, index) => buffer.writeUInt16BE(word, index * 2));
switch (type) {
case "int16":
return buffer.readInt16BE(0);
case "uint16":
return buffer.readUInt16BE(0);
case "int32":
return buffer.readInt32BE(0);
case "uint32":
return buffer.readUInt32BE(0);
case "float32":
return buffer.readFloatBE(0);
default:
throw new Error(`Tipo non supportato: ${type}`);
}
}
export function encodeInt16(value) {
// Converte un valore con segno nella rappresentazione a 16 bit del registro
if (!Number.isInteger(value) || value < -32768 || value > 32767) {
throw new RangeError(`Valore fuori dall'intervallo int16: ${value}`);
}
return value < 0 ? value + 0x10000 : value;
}
export function applyScale(raw, tag) {
const scaled = tag.scale ? raw * tag.scale : raw;
// Arrotonda per eliminare gli artefatti della virgola mobile (21.700000000000003)
return tag.scale ? Math.round(scaled * 1e6) / 1e6 : scaled;
}
Quando un valore letto sembra assurdo, per esempio una pressione di 4,6 per dieci alla trentesima bar, quasi sempre il problema è l'ordine delle word. Il modo più rapido per scoprirlo è leggere un valore noto dal pannello del PLC e provare le due combinazioni.
Raggruppare le letture in blocchi
Ogni richiesta Modbus costa un viaggio di andata e ritorno in rete e un ciclo di elaborazione nel PLC, che spesso ha risorse limitate. Leggere dieci tag con dieci richieste separate è uno spreco: se gli indirizzi sono vicini, conviene leggere un unico blocco contiguo e poi estrarre i singoli valori. Una richiesta di lettura registri può contenere al massimo 125 registri, limite imposto dalla dimensione massima della PDU.
// planner.js
import { registerCount } from "./decoder.js";
const MAX_REGISTERS = 125;
export function planBlocks(tags, maxGap = 8) {
const blocks = [];
const byTable = Map.groupBy(tags, (tag) => tag.table);
for (const [table, tableTags] of byTable) {
const sorted = [...tableTags].sort((a, b) => a.address - b.address);
let current = null;
for (const tag of sorted) {
const end = tag.address + registerCount(tag.type);
const fits =
current &&
tag.address - current.end <= maxGap &&
end - current.start <= MAX_REGISTERS;
if (fits) {
current.end = Math.max(current.end, end);
current.tags.push(tag);
} else {
current = { table, start: tag.address, end, tags: [tag] };
blocks.push(current);
}
}
}
return blocks;
}
Il parametro maxGap stabilisce quanti registri inutilizzati si accetta di leggere pur di unire due tag nello stesso blocco. Attenzione però: alcuni PLC rispondono con l'eccezione Illegal Data Address se il blocco include registri non mappati. In quel caso basta impostare maxGap a zero.
Il poller Modbus
Il cuore del gateway è una classe che mantiene la connessione, esegue i cicli di lettura ed emette eventi. Tre scelte di progetto meritano attenzione.
- Il ciclo usa
setTimeoutconcatenati invece disetInterval: se un ciclo dura più dell'intervallo, per esempio perché il PLC risponde lentamente, non si accumulano richieste sovrapposte. - Ogni valore porta con sé una qualità, sul modello di OPC UA:
goodse l'ultima lettura è riuscita,badse è fallita. Un valore vecchio presentato come attuale è più pericoloso di un valore mancante. - La riconnessione usa un ritardo crescente con un tetto massimo, per non saturare un dispositivo che si sta riavviando.
// modbus-poller.js
import { EventEmitter } from "node:events";
import ModbusRTU from "modbus-serial";
import { decodeRegisters, applyScale, encodeInt16, registerCount } from "./decoder.js";
import { planBlocks } from "./planner.js";
export class ModbusPoller extends EventEmitter {
#client = new ModbusRTU();
#blocks;
#timer = null;
#running = false;
#backoffMs = 1000;
constructor(options, tags) {
super();
this.options = options;
this.tags = new Map(tags.map((tag) => [tag.name, tag]));
this.#blocks = planBlocks(tags);
}
async start() {
this.#running = true;
await this.#schedule(0);
}
async stop() {
this.#running = false;
clearTimeout(this.#timer);
if (this.#client.isOpen) {
await new Promise((resolve) => this.#client.close(resolve));
}
}
async #schedule(delayMs) {
if (!this.#running) return;
this.#timer = setTimeout(() => this.#cycle(), delayMs);
}
async #ensureConnected() {
if (this.#client.isOpen) return;
const { host, port, unitId, timeoutMs } = this.options;
await this.#client.connectTCP(host, { port });
this.#client.setID(unitId);
this.#client.setTimeout(timeoutMs);
this.#backoffMs = 1000;
this.emit("connected", { host, port });
}
async #cycle() {
try {
await this.#ensureConnected();
for (const block of this.#blocks) {
await this.#readBlock(block);
}
await this.#schedule(this.options.pollIntervalMs);
} catch (error) {
this.emit("error", error);
this.#markAllBad(error.message);
// Chiude il socket per forzare una riconnessione pulita al ciclo successivo
if (this.#client.isOpen) this.#client.close(() => {});
await this.#schedule(this.#backoffMs);
this.#backoffMs = Math.min(this.#backoffMs * 2, 30_000);
}
}
async #readBlock(block) {
const length = block.end - block.start;
const response =
block.table === "holding"
? await this.#client.readHoldingRegisters(block.start, length)
: await this.#client.readInputRegisters(block.start, length);
const timestamp = new Date().toISOString();
for (const tag of block.tags) {
const offset = tag.address - block.start;
const slice = response.data.slice(offset, offset + registerCount(tag.type));
const raw = decodeRegisters(slice, tag.type, tag.wordOrder);
this.emit("value", {
name: tag.name,
value: applyScale(raw, tag),
unit: tag.unit ?? null,
quality: "good",
timestamp,
});
}
}
#markAllBad(reason) {
const timestamp = new Date().toISOString();
for (const tag of this.tags.values()) {
this.emit("value", { name: tag.name, value: null, quality: "bad", reason, timestamp });
}
}
async writeTag(name, engineeringValue) {
const tag = this.tags.get(name);
if (!tag || !tag.writable) throw new Error(`Tag non scrivibile: ${name}`);
if (typeof engineeringValue !== "number" || Number.isNaN(engineeringValue)) {
throw new TypeError("Il valore deve essere numerico");
}
if (engineeringValue < tag.min || engineeringValue > tag.max) {
throw new RangeError(`Valore ${engineeringValue} fuori dai limiti [${tag.min}, ${tag.max}]`);
}
// Riporta il valore ingegneristico al valore grezzo del registro
const raw = Math.round(engineeringValue / (tag.scale ?? 1));
await this.#ensureConnected();
await this.#client.writeRegister(tag.address, encodeInt16(raw));
// Rilegge il registro per verificare che il PLC abbia accettato il valore
const check = await this.#client.readHoldingRegisters(tag.address, 1);
const readBack = applyScale(decodeRegisters(check.data, tag.type), tag);
if (readBack !== applyScale(raw, tag)) {
throw new Error(`Verifica fallita: scritto ${engineeringValue}, riletto ${readBack}`);
}
return readBack;
}
}
La rilettura dopo la scrittura non è un eccesso di zelo. Molti PLC applicano limiti propri, ignorano le scritture quando sono in modalità manuale o sovrascrivono il registro nel ciclo successivo. Solo rileggendo si sa cosa è effettivamente in vigore. Notate anche che in caso di errore la scrittura non viene ritentata automaticamente: se la risposta è andata persa dopo che il PLC ha eseguito il comando, ripeterlo potrebbe produrre un effetto doppio. Per un setpoint è innocuo, per un comando di avanzamento passo non lo è.
Un'ultima osservazione riguarda la concorrenza: modbus-serial gestisce una richiesta alla volta per connessione. Se una scrittura arriva mentre il ciclo di polling è in corso, le due operazioni possono intrecciarsi. In un gateway reale conviene serializzare tutte le operazioni su una coda, oppure aprire una seconda connessione dedicata ai comandi, se il PLC accetta più client.
Sottoscrizioni OPC UA
Con OPC UA il polling non è necessario. Il client crea una sessione, poi una sottoscrizione con un intervallo di pubblicazione, e infine aggiunge un monitored item per ogni nodo da osservare. Il server campiona i valori alla frequenza richiesta e invia solo le variazioni.
// opcua-monitor.js
import { EventEmitter } from "node:events";
import {
OPCUAClient,
AttributeIds,
TimestampsToReturn,
MessageSecurityMode,
SecurityPolicy,
} from "node-opcua";
export class OpcUaMonitor extends EventEmitter {
#client;
#session = null;
#subscription = null;
constructor(options) {
super();
this.options = options;
this.#client = OPCUAClient.create({
applicationName: "scada-gateway",
endpointMustExist: false,
// In produzione usare SignAndEncrypt con Basic256Sha256 o una policy più recente
securityMode: MessageSecurityMode.None,
securityPolicy: SecurityPolicy.None,
connectionStrategy: { initialDelay: 1000, maxDelay: 20_000, maxRetry: Number.MAX_SAFE_INTEGER },
});
this.#client.on("connection_lost", () => this.emit("status", "connection_lost"));
this.#client.on("connection_reestablished", () => this.emit("status", "reconnected"));
}
async start() {
await this.#client.connect(this.options.endpointUrl);
this.#session = await this.#client.createSession();
this.#subscription = await this.#session.createSubscription2({
requestedPublishingInterval: 500,
requestedLifetimeCount: 100,
requestedMaxKeepAliveCount: 10,
maxNotificationsPerPublish: 100,
publishingEnabled: true,
priority: 10,
});
for (const node of this.options.nodes) {
const item = await this.#subscription.monitor(
{ nodeId: node.nodeId, attributeId: AttributeIds.Value },
{ samplingInterval: 250, discardOldest: true, queueSize: 10 },
TimestampsToReturn.Both,
);
item.on("changed", (dataValue) => {
const good = dataValue.statusCode.isGood();
this.emit("value", {
name: node.name,
value: good ? dataValue.value.value : null,
quality: good ? "good" : "bad",
status: dataValue.statusCode.name,
// Il timestamp della sorgente indica quando il dato è stato prodotto sul campo
timestamp: (dataValue.sourceTimestamp ?? new Date()).toISOString(),
});
});
}
}
async stop() {
await this.#subscription?.terminate();
await this.#session?.close();
await this.#client.disconnect();
}
}
Due parametri meritano una spiegazione. L'intervallo di campionamento (samplingInterval) dice al server ogni quanto controllare il valore; l'intervallo di pubblicazione dice ogni quanto inviare al client le notifiche accumulate. Con una coda di dieci elementi e discardOldest attivo, se il valore cambia più volte tra due pubblicazioni il client riceve le variazioni intermedie, scartando le più vecchie in caso di traboccamento. Il server può rivedere i valori richiesti in base alle proprie capacità.
Il campo sourceTimestamp è importante: indica quando il valore è stato prodotto dalla sorgente, non quando è arrivato al gateway. Per un sistema storico questa distinzione fa la differenza tra dati corretti e dati sfasati di qualche secondo.
Il gateway WebSocket
Il pezzo finale unisce le due sorgenti e le espone ai browser. Il gateway mantiene in memoria l'ultimo valore di ogni tag, così un client appena connesso riceve subito un'istantanea completa invece di attendere il cambiamento successivo.
// gateway.js
import { WebSocketServer, WebSocket } from "ws";
import { config } from "./config.js";
import { ModbusPoller } from "./modbus-poller.js";
import { OpcUaMonitor } from "./opcua-monitor.js";
const snapshot = new Map();
const wss = new WebSocketServer({ port: config.gateway.port });
const poller = new ModbusPoller(config.modbus, config.tags);
const monitor = new OpcUaMonitor(config.opcua);
function broadcast(message) {
const payload = JSON.stringify(message);
for (const client of wss.clients) {
if (client.readyState === WebSocket.OPEN) client.send(payload);
}
}
function handleValue(reading) {
const previous = snapshot.get(reading.name);
snapshot.set(reading.name, reading);
// Inoltra solo le variazioni di valore o di qualità
if (previous?.value !== reading.value || previous?.quality !== reading.quality) {
broadcast({ type: "value", data: reading });
}
}
poller.on("value", handleValue);
monitor.on("value", handleValue);
poller.on("error", (error) => console.error("[modbus]", error.message));
monitor.on("status", (status) => console.warn("[opcua]", status));
wss.on("connection", (socket) => {
socket.send(JSON.stringify({ type: "snapshot", data: [...snapshot.values()] }));
socket.on("message", async (raw) => {
let request;
try {
request = JSON.parse(raw.toString());
} catch {
socket.send(JSON.stringify({ type: "error", message: "JSON non valido" }));
return;
}
if (request.type !== "write") return;
try {
const value = await poller.writeTag(request.name, request.value);
console.info(`[audit] scrittura ${request.name}=${value}`);
socket.send(JSON.stringify({ type: "write_ok", name: request.name, value }));
} catch (error) {
socket.send(JSON.stringify({ type: "write_error", name: request.name, message: error.message }));
}
});
});
async function shutdown() {
console.info("Arresto del gateway in corso");
wss.close();
await Promise.allSettled([poller.stop(), monitor.stop()]);
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
await poller.start();
await monitor.start().catch((error) => console.error("[opcua] avvio fallito:", error.message));
console.info(`Gateway in ascolto su ws://0.0.0.0:${config.gateway.port}`);
Il filtro sulle variazioni riduce drasticamente il traffico: un serbatoio la cui temperatura resta stabile per ore non genera alcun messaggio, mentre il passaggio di un tag da good a bad viene propagato subito. Per un tag analogico rumoroso sarebbe opportuno aggiungere una deadband, cioè ignorare le variazioni inferiori a una soglia.
Sicurezza: la parte che non si può saltare
Collegare un impianto industriale a un'applicazione web significa aprire un ponte tra due mondi con modelli di minaccia molto diversi. Alcune regole sono irrinunciabili.
- Modbus non ha alcuna autenticazione. Chiunque raggiunga la porta 502 può leggere e scrivere qualsiasi registro. Il PLC deve stare in una rete isolata e il gateway deve essere l'unico punto di contatto, secondo il modello a zone e condotti della norma IEC 62443.
- Il gateway deve essere in sola lettura per impostazione predefinita. Le scritture vanno abilitate tag per tag, con limiti espliciti, come nella nostra configurazione.
- Il WebSocket dell'esempio non è autenticato. In produzione va messo dietro un reverse proxy con TLS, l'utente va autenticato prima dell'upgrade della connessione e i permessi di scrittura vanno verificati per ogni richiesta.
- Ogni scrittura va registrata con utente, valore precedente, valore nuovo e orario. In ambito industriale il registro delle operazioni è spesso un requisito normativo.
- OPC UA va configurato con la sicurezza attiva. La modalità
Noneusata nell'esempio è accettabile solo in laboratorio: in produzione servonoSignAndEncrypt, certificati gestiti e utenti con privilegi minimi.
Conclusioni
Abbiamo costruito un gateway che affronta i problemi concreti dell'integrazione con un sistema SCADA: la traduzione di registri grezzi in grandezze ingegneristiche, l'ottimizzazione delle letture, la gestione della qualità del dato, la riconnessione, le sottoscrizioni OPC UA e le scritture verificate. Il passo successivo naturale è aggiungere la persistenza, per esempio scrivendo le letture in un database per serie temporali, e un sistema di allarmi con isteresi. Qualunque estensione scegliate, il principio resta lo stesso: il software che parla con un impianto deve sempre sapere, e comunicare, quanto è affidabile il dato che sta mostrando.