Interagire con i sistemi SCADA in PHP

Interagire con i sistemi SCADA in PHP

PHP non è il primo linguaggio che viene in mente parlando di automazione industriale, eppure capita spesso di doverlo usare in questo contesto. Molte aziende manifatturiere hanno gestionali, portali interni e sistemi di reportistica scritti in PHP, e prima o poi qualcuno chiede di mostrare in quelle pagine i dati di produzione che arrivano dalle macchine: il numero di pezzi prodotti dal turno, la temperatura di un forno, lo stato di una linea. A quel punto bisogna decidere come far parlare il gestionale con l'impianto.

In questo articolo affronteremo il problema nel modo più istruttivo possibile: implementeremo da zero un client Modbus TCP usando solo i socket di PHP, senza librerie esterne. Costruire il protocollo a mano ha un doppio vantaggio: elimina dipendenze e, soprattutto, mostra esattamente cosa viaggia sul filo, conoscenza preziosa quando bisogna diagnosticare un problema con un PLC. Useremo poi il client in un processo da riga di comando che acquisisce i dati e li salva in MySQL, mentre l'applicazione web legge solo dal database.

Il posto giusto per PHP in un'architettura SCADA

Un sistema SCADA comprende i dispositivi di campo (PLC e RTU) che eseguono la logica di controllo, i server di supervisione, le HMI degli operatori e lo storico dei dati. Il modello di esecuzione tipico di PHP, in cui ogni richiesta HTTP nasce e muore in pochi millisecondi, non si sposa bene con l'acquisizione continua: aprire una connessione verso il PLC a ogni visita di pagina significherebbe moltiplicare il carico sul dispositivo per il numero di utenti, con tempi di risposta legati alla rete industriale.

L'architettura corretta separa due ruoli:

  • un processo di acquisizione da riga di comando, sempre attivo e gestito da systemd, che mantiene una sola connessione verso ogni PLC, legge a intervalli regolari e scrive nel database;
  • l'applicazione web, che legge esclusivamente dal database e non ha alcun accesso di rete verso l'impianto.

Questa separazione non è solo una questione di prestazioni. È anche una barriera di sicurezza: il server web, esposto agli utenti, non può raggiungere i PLC nemmeno se viene compromesso.

Anatomia di un frame Modbus TCP

Modbus organizza i dati del dispositivo in quattro tabelle: coil (bit in lettura e scrittura), discrete input (bit in sola lettura), holding register e input register (parole di 16 bit, rispettivamente in lettura e scrittura e in sola lettura). Una richiesta TCP è composta da un'intestazione MBAP di sette byte seguita dalla PDU, cioè il codice funzione e i suoi parametri. Tutti i campi multibyte sono big-endian.

  • Transaction identifier (2 byte): un numero scelto dal client, che il server ricopia nella risposta per permettere di associarla alla richiesta.
  • Protocol identifier (2 byte): sempre zero per Modbus.
  • Length (2 byte): il numero di byte che seguono, cioè l'unit identifier più la PDU.
  • Unit identifier (1 byte): l'indirizzo del dispositivo, rilevante quando dietro l'indirizzo IP c'è un gateway verso una linea seriale.
  • Function code (1 byte) e dati: per la lettura degli holding register (codice 3) i dati sono l'indirizzo iniziale e il numero di registri, due byte ciascuno.

Una richiesta di lettura di due registri a partire dall'indirizzo 100, sul dispositivo 1, è quindi la sequenza esadecimale 00 01 00 00 00 06 01 03 00 64 00 02. La risposta riporta lo stesso identificativo, il codice funzione, il numero di byte di dati e poi i registri. Se il dispositivo non può eseguire la richiesta, risponde con il codice funzione con il bit più significativo impostato (per la lettura, 0x83) seguito da un codice di eccezione.

Il client Modbus

Il client usa stream_socket_client con un timeout di connessione e stream_set_timeout per le letture. La funzione più delicata è readExact: TCP è un flusso di byte, e una singola chiamata di lettura può restituire meno byte di quelli richiesti. Bisogna continuare a leggere finché il frame è completo, controllando ogni volta se il timeout è scaduto.

<?php
// src/Modbus/ModbusException.php
declare(strict_types=1);

namespace Scada\Modbus;

final class ModbusException extends \RuntimeException
{
    private const EXCEPTION_NAMES = [
        0x01 => 'Illegal Function',
        0x02 => 'Illegal Data Address',
        0x03 => 'Illegal Data Value',
        0x04 => 'Server Device Failure',
        0x06 => 'Server Device Busy',
        0x0A => 'Gateway Path Unavailable',
        0x0B => 'Gateway Target Device Failed To Respond',
    ];

    public static function fromExceptionCode(int $functionCode, int $exceptionCode): self
    {
        $name = self::EXCEPTION_NAMES[$exceptionCode] ?? 'Unknown Exception';
        return new self(sprintf('Funzione 0x%02X: %s (0x%02X)', $functionCode, $name, $exceptionCode), $exceptionCode);
    }

    // Un'eccezione di protocollo indica che il dispositivo ha risposto: la connessione resta valida
    public function isProtocolException(): bool
    {
        return $this->getCode() > 0;
    }
}
<?php
// src/Modbus/ModbusTcpClient.php
declare(strict_types=1);

namespace Scada\Modbus;

final class ModbusTcpClient
{
    private const READ_HOLDING_REGISTERS = 0x03;
    private const READ_INPUT_REGISTERS = 0x04;
    private const WRITE_SINGLE_REGISTER = 0x06;
    private const MAX_REGISTERS = 125;

    /** @var resource|null */
    private $socket = null;
    private int $transactionId = 0;

    public function __construct(
        private readonly string $host,
        private readonly int $port = 502,
        private readonly int $unitId = 1,
        private readonly float $timeout = 2.0,
    ) {
    }

    public function connect(): void
    {
        if ($this->socket !== null) {
            return;
        }
        $address = sprintf('tcp://%s:%d', $this->host, $this->port);
        $socket = @stream_socket_client($address, $errorCode, $errorMessage, $this->timeout);
        if ($socket === false) {
            throw new ModbusException(sprintf('Connessione a %s fallita: %s (%d)', $address, $errorMessage, $errorCode));
        }
        stream_set_blocking($socket, true);
        stream_set_timeout($socket, (int) $this->timeout, (int) (($this->timeout - floor($this->timeout)) * 1_000_000));
        $this->socket = $socket;
    }

    public function close(): void
    {
        if ($this->socket !== null) {
            fclose($this->socket);
            $this->socket = null;
        }
    }

    public function isConnected(): bool
    {
        return $this->socket !== null;
    }

    /** @return list<int> */
    public function readHoldingRegisters(int $address, int $quantity): array
    {
        return $this->readRegisters(self::READ_HOLDING_REGISTERS, $address, $quantity);
    }

    /** @return list<int> */
    public function readInputRegisters(int $address, int $quantity): array
    {
        return $this->readRegisters(self::READ_INPUT_REGISTERS, $address, $quantity);
    }

    public function writeSingleRegister(int $address, int $value): void
    {
        if ($value < 0 || $value > 0xFFFF) {
            throw new \InvalidArgumentException("Valore di registro non valido: {$value}");
        }
        $pdu = pack('Cnn', self::WRITE_SINGLE_REGISTER, $address, $value);
        $response = $this->transact($pdu);

        // La risposta di una scrittura singola è l'eco esatta della richiesta
        if ($response !== $pdu) {
            throw new ModbusException('Eco della scrittura non corrispondente alla richiesta');
        }
    }

    /** @return list<int> */
    private function readRegisters(int $functionCode, int $address, int $quantity): array
    {
        if ($quantity < 1 || $quantity > self::MAX_REGISTERS) {
            throw new \InvalidArgumentException("Quantità di registri non valida: {$quantity}");
        }
        if ($address < 0 || $address + $quantity > 0x10000) {
            throw new \InvalidArgumentException("Indirizzo fuori intervallo: {$address}");
        }

        $response = $this->transact(pack('Cnn', $functionCode, $address, $quantity));
        $byteCount = ord($response[1]);
        if ($byteCount !== $quantity * 2 || strlen($response) !== 2 + $byteCount) {
            throw new ModbusException("Lunghezza della risposta inattesa: {$byteCount} byte");
        }
        // unpack con 'n*' restituisce un array indicizzato da 1: array_values lo riporta a base zero
        return array_values(unpack('n*', substr($response, 2)));
    }

    private function transact(string $pdu): string
    {
        $this->connect();
        $this->transactionId = ($this->transactionId + 1) & 0xFFFF;
        $header = pack('nnnC', $this->transactionId, 0, strlen($pdu) + 1, $this->unitId);

        try {
            $this->writeAll($header . $pdu);
            $mbap = unpack('ntransaction/nprotocol/nlength/Cunit', $this->readExact(7));
            if ($mbap['transaction'] !== $this->transactionId || $mbap['protocol'] !== 0) {
                throw new ModbusException('Intestazione MBAP non valida o fuori sequenza');
            }
            if ($mbap['length'] < 2 || $mbap['length'] > 254) {
                throw new ModbusException("Lunghezza MBAP non valida: {$mbap['length']}");
            }
            $response = $this->readExact($mbap['length'] - 1);
        } catch (ModbusException $exception) {
            // Dopo un errore di trasporto il flusso potrebbe essere desincronizzato
            $this->close();
            throw $exception;
        }

        $functionCode = ord($response[0]);
        $requestedCode = ord($pdu[0]);
        if ($functionCode === ($requestedCode | 0x80)) {
            throw ModbusException::fromExceptionCode($requestedCode, ord($response[1]));
        }
        if ($functionCode !== $requestedCode) {
            $this->close();
            throw new ModbusException(sprintf('Codice funzione inatteso: 0x%02X', $functionCode));
        }
        return $response;
    }

    private function writeAll(string $data): void
    {
        $written = 0;
        $length = strlen($data);
        while ($written < $length) {
            $result = @fwrite($this->socket, substr($data, $written));
            if ($result === false || $result === 0) {
                throw new ModbusException('Scrittura sul socket fallita');
            }
            $written += $result;
        }
    }

    private function readExact(int $length): string
    {
        $buffer = '';
        while (strlen($buffer) < $length) {
            $chunk = fread($this->socket, $length - strlen($buffer));
            $meta = stream_get_meta_data($this->socket);
            if ($meta['timed_out']) {
                throw new ModbusException('Timeout in attesa della risposta del dispositivo');
            }
            if ($chunk === false || ($chunk === '' && feof($this->socket))) {
                throw new ModbusException('Connessione chiusa dal dispositivo');
            }
            $buffer .= $chunk;
        }
        return $buffer;
    }
}

Il controllo sul transaction identifier merita due parole. Se una risposta arriva dopo il timeout, il client ha già rinunciato alla richiesta precedente e ne ha inviata una nuova: senza confronto dell'identificativo, la risposta tardiva verrebbe interpretata come risposta alla nuova richiesta, con valori del tutto sbagliati. Per questo, a qualunque errore di trasporto, il client chiude il socket: ricominciare con una connessione pulita è il modo più semplice per non leggere dati fuori sequenza.

Le eccezioni Modbus vere e proprie, invece, non richiedono la chiusura: il dispositivo ha risposto correttamente, semplicemente rifiutando la richiesta.

Decodificare i valori

Un registro contiene 16 bit senza tipo. Interpretarli è compito del client, sulla base della documentazione del PLC. Per i valori a 32 bit, come float o contatori, servono due registri; il protocollo non stabilisce quale dei due contenga la parte alta, e i costruttori adottano convenzioni diverse.

<?php
// src/Modbus/DataType.php
declare(strict_types=1);

namespace Scada\Modbus;

enum DataType: string
{
    case Int16 = 'int16';
    case Uint16 = 'uint16';
    case Int32 = 'int32';
    case Uint32 = 'uint32';
    case Float32 = 'float32';

    public function registerCount(): int
    {
        return match ($this) {
            self::Int16, self::Uint16 => 1,
            self::Int32, self::Uint32, self::Float32 => 2,
        };
    }

    /** @param list<int> $registers */
    public function decode(array $registers, bool $lowWordFirst = false): int|float
    {
        if ($lowWordFirst) {
            $registers = array_reverse($registers);
        }
        // Ricostruisce i byte in ordine big-endian
        $bytes = pack('n*', ...$registers);

        return match ($this) {
            self::Uint16 => unpack('n', $bytes)[1],
            self::Int16 => self::toSigned(unpack('n', $bytes)[1], 16),
            self::Uint32 => unpack('N', $bytes)[1],
            self::Int32 => self::toSigned(unpack('N', $bytes)[1], 32),
            // 'G' indica un float a precisione singola big-endian
            self::Float32 => unpack('G', $bytes)[1],
        };
    }

    private static function toSigned(int $value, int $bits): int
    {
        $limit = 1 << ($bits - 1);
        return $value >= $limit ? $value - (1 << $bits) : $value;
    }
}

PHP non ha un formato di unpack per gli interi con segno big-endian, quindi si legge il valore senza segno e lo si converte con il complemento a due. Il formato G, invece, decodifica direttamente un float IEEE 754 a precisione singola in ordine big-endian. Il valore restituito è un float PHP a doppia precisione: un float32 come 21,7 diventerà 21,700000762939453, perché 21,7 non è rappresentabile esattamente in 32 bit. Prima di mostrarlo conviene arrotondarlo alla precisione significativa per la grandezza.

La mappa dei tag

La descrizione delle variabili da leggere vive in un array di configurazione. Ogni tag specifica tabella, indirizzo a base zero, tipo, eventuale fattore di scala e ordine delle word.

<?php
// config/tags.php
declare(strict_types=1);

use Scada\Modbus\DataType;

return [
    'device' => [
        'host' => getenv('PLC_HOST') ?: '10.10.1.20',
        'port' => 502,
        'unit_id' => 1,
        'timeout' => 1.5,
        'interval_ms' => 2000,
    ],
    'tags' => [
        ['name' => 'oven.temperature', 'table' => 'holding', 'address' => 100, 'type' => DataType::Int16, 'scale' => 0.1, 'unit' => '°C'],
        ['name' => 'oven.setpoint', 'table' => 'holding', 'address' => 101, 'type' => DataType::Int16, 'scale' => 0.1, 'unit' => '°C'],
        ['name' => 'line.speed', 'table' => 'holding', 'address' => 102, 'type' => DataType::Float32, 'scale' => 1.0, 'unit' => 'm/min'],
        ['name' => 'line.pieces', 'table' => 'input', 'address' => 0, 'type' => DataType::Uint32, 'low_word_first' => true, 'unit' => 'pz'],
        ['name' => 'line.state', 'table' => 'input', 'address' => 2, 'type' => DataType::Uint16, 'unit' => ''],
    ],
];

Il lettore a blocchi

Leggere ogni tag con una richiesta separata moltiplica il traffico e il carico sul PLC. Il lettore raggruppa i tag della stessa tabella in blocchi contigui e li legge con una sola richiesta per blocco, poi estrae i valori dall'array di registri.

<?php
// src/TagReader.php
declare(strict_types=1);

namespace Scada;

use Scada\Modbus\ModbusException;
use Scada\Modbus\ModbusTcpClient;

final class TagReader
{
    private const MAX_GAP = 4;
    private const MAX_BLOCK = 125;

    /** @var list<array{table: string, start: int, end: int, tags: list<array<string, mixed>>}> */
    private array $blocks;

    /** @param list<array<string, mixed>> $tags */
    public function __construct(private readonly ModbusTcpClient $client, array $tags)
    {
        $this->blocks = $this->planBlocks($tags);
    }

    /** @return list<array{name: string, value: float|int|null, quality: string, unit: string}> */
    public function readAll(): array
    {
        $readings = [];
        foreach ($this->blocks as $block) {
            $quantity = $block['end'] - $block['start'];
            try {
                $registers = $block['table'] === 'holding'
                    ? $this->client->readHoldingRegisters($block['start'], $quantity)
                    : $this->client->readInputRegisters($block['start'], $quantity);
            } catch (ModbusException $exception) {
                foreach ($block['tags'] as $tag) {
                    $readings[] = ['name' => $tag['name'], 'value' => null, 'quality' => 'bad', 'unit' => $tag['unit']];
                }
                if (!$exception->isProtocolException()) {
                    // Errore di rete: inutile tentare gli altri blocchi in questo ciclo
                    throw $exception;
                }
                continue;
            }

            foreach ($block['tags'] as $tag) {
                $offset = $tag['address'] - $block['start'];
                $slice = array_slice($registers, $offset, $tag['type']->registerCount());
                $value = $tag['type']->decode($slice, $tag['low_word_first'] ?? false);
                $scale = $tag['scale'] ?? 1.0;
                $readings[] = [
                    'name' => $tag['name'],
                    'value' => $scale === 1.0 ? $value : round($value * $scale, 4),
                    'quality' => 'good',
                    'unit' => $tag['unit'],
                ];
            }
        }
        return $readings;
    }

    /**
     * @param list<array<string, mixed>> $tags
     * @return list<array{table: string, start: int, end: int, tags: list<array<string, mixed>>}>
     */
    private function planBlocks(array $tags): array
    {
        usort($tags, fn (array $a, array $b): int => [$a['table'], $a['address']] <=> [$b['table'], $b['address']]);
        $blocks = [];
        $current = null;

        foreach ($tags as $tag) {
            $end = $tag['address'] + $tag['type']->registerCount();
            $fits = $current !== null
                && $current['table'] === $tag['table']
                && $tag['address'] - $current['end'] <= self::MAX_GAP
                && $end - $current['start'] <= self::MAX_BLOCK;

            if ($fits) {
                $current['end'] = max($current['end'], $end);
                $current['tags'][] = $tag;
                continue;
            }
            if ($current !== null) {
                $blocks[] = $current;
            }
            $current = ['table' => $tag['table'], 'start' => $tag['address'], 'end' => $end, 'tags' => [$tag]];
        }
        if ($current !== null) {
            $blocks[] = $current;
        }
        return $blocks;
    }
}

Il processo di acquisizione

Lo script da riga di comando gira in un ciclo infinito. Gestisce i segnali SIGTERM e SIGINT tramite l'estensione pcntl, così systemd può arrestarlo in modo ordinato, mantiene la cadenza sottraendo la durata del ciclo all'attesa e, in caso di errore di rete, attende con un ritardo crescente prima di riprovare.

<?php
// bin/collector.php
declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

use Scada\Modbus\ModbusException;
use Scada\Modbus\ModbusTcpClient;
use Scada\TagReader;

$config = require __DIR__ . '/../config/tags.php';
$device = $config['device'];

$pdo = new PDO(
    getenv('DB_DSN') ?: 'mysql:host=127.0.0.1;dbname=scada;charset=utf8mb4',
    getenv('DB_USER') ?: 'collector',
    getenv('DB_PASSWORD') ?: '',
    [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION],
);
$insert = $pdo->prepare(
    'INSERT INTO tag_readings (tag, value, quality, unit, read_at) VALUES (:tag, :value, :quality, :unit, :read_at)'
);
$upsert = $pdo->prepare(
    'INSERT INTO tag_current (tag, value, quality, unit, read_at) VALUES (:tag, :value, :quality, :unit, :read_at)
     ON DUPLICATE KEY UPDATE value = VALUES(value), quality = VALUES(quality), unit = VALUES(unit), read_at = VALUES(read_at)'
);

$client = new ModbusTcpClient($device['host'], $device['port'], $device['unit_id'], $device['timeout']);
$reader = new TagReader($client, $config['tags']);

$running = true;
pcntl_async_signals(true);
pcntl_signal(SIGTERM, function () use (&$running): void { $running = false; });
pcntl_signal(SIGINT, function () use (&$running): void { $running = false; });

$backoffSeconds = 1;
$intervalMicros = $device['interval_ms'] * 1000;

while ($running) {
    $cycleStart = hrtime(true);
    try {
        $readings = $reader->readAll();
        $backoffSeconds = 1;
    } catch (ModbusException $exception) {
        fwrite(STDERR, sprintf("[%s] %s\n", date('c'), $exception->getMessage()));
        $client->close();
        $readings = array_map(
            fn (array $tag): array => ['name' => $tag['name'], 'value' => null, 'quality' => 'bad', 'unit' => $tag['unit']],
            $config['tags'],
        );
        storeReadings($pdo, $insert, $upsert, $readings);
        sleep($backoffSeconds);
        $backoffSeconds = min($backoffSeconds * 2, 30);
        continue;
    }

    storeReadings($pdo, $insert, $upsert, $readings);

    $elapsedMicros = intdiv(hrtime(true) - $cycleStart, 1000);
    if ($elapsedMicros < $intervalMicros) {
        usleep($intervalMicros - $elapsedMicros);
    }
}

$client->close();
fwrite(STDOUT, "Collector arrestato\n");

/** @param list<array{name: string, value: float|int|null, quality: string, unit: string}> $readings */
function storeReadings(PDO $pdo, PDOStatement $insert, PDOStatement $upsert, array $readings): void
{
    $readAt = (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s.v');
    $pdo->beginTransaction();
    try {
        foreach ($readings as $reading) {
            $params = [
                'tag' => $reading['name'],
                'value' => $reading['value'],
                'quality' => $reading['quality'],
                'unit' => $reading['unit'],
                'read_at' => $readAt,
            ];
            $insert->execute($params);
            $upsert->execute($params);
        }
        $pdo->commit();
    } catch (Throwable $exception) {
        $pdo->rollBack();
        fwrite(STDERR, sprintf("[%s] salvataggio fallito: %s\n", date('c'), $exception->getMessage()));
    }
}

Lo script presuppone l'autoload PSR-4 di Composer per lo spazio dei nomi Scada mappato sulla cartella src. Le tabelle sono due: tag_readings conserva lo storico, tag_current contiene una sola riga per tag con l'ultimo valore, così le pagine che mostrano lo stato attuale non devono cercare l'ultimo record in una tabella che cresce di milioni di righe.

CREATE TABLE tag_readings (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    tag VARCHAR(64) NOT NULL,
    value DOUBLE NULL,
    quality ENUM('good', 'bad') NOT NULL,
    unit VARCHAR(16) NOT NULL DEFAULT '',
    read_at DATETIME(3) NOT NULL,
    INDEX idx_tag_time (tag, read_at)
) ENGINE=InnoDB;

CREATE TABLE tag_current (
    tag VARCHAR(64) PRIMARY KEY,
    value DOUBLE NULL,
    quality ENUM('good', 'bad') NOT NULL,
    unit VARCHAR(16) NOT NULL DEFAULT '',
    read_at DATETIME(3) NOT NULL
) ENGINE=InnoDB;

Con una lettura ogni due secondi e una decina di tag, lo storico cresce di circa quattrocentomila righe al giorno. È un volume che MySQL gestisce senza problemi, ma che va pianificato: conviene partizionare la tabella per mese o aggregare periodicamente i dati più vecchi in medie orarie.

Per l'esecuzione continua basta un'unità systemd che riavvii il processo in caso di arresto anomalo.

# /etc/systemd/system/scada-collector.service
[Unit]
Description=Collector Modbus per il gestionale
After=network-online.target mysql.service
Wants=network-online.target

[Service]
Type=simple
User=scada
ExecStart=/usr/bin/php /opt/scada-collector/bin/collector.php
Restart=always
RestartSec=5
EnvironmentFile=/etc/scada-collector.env

[Install]
WantedBy=multi-user.target

L'endpoint di lettura per l'applicazione web

Dal lato web, esporre lo stato attuale come JSON è banale, ed è giusto che lo sia: la complessità sta tutta nel collector. Si noti che l'endpoint riporta anche l'età di ogni valore. Se il collector si è fermato, i dati in tag_current restano lì e sembrerebbero attuali; marcarli come obsoleti oltre una certa età evita che un operatore prenda decisioni su informazioni vecchie.

<?php
// public/api/tags.php
declare(strict_types=1);

const STALE_AFTER_SECONDS = 10;

$pdo = new PDO(
    getenv('DB_DSN') ?: 'mysql:host=127.0.0.1;dbname=scada;charset=utf8mb4',
    getenv('DB_READER_USER') ?: 'dashboard',
    getenv('DB_READER_PASSWORD') ?: '',
    [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION],
);

$rows = $pdo->query(
    'SELECT tag, value, quality, unit, read_at, TIMESTAMPDIFF(SECOND, read_at, UTC_TIMESTAMP()) AS age FROM tag_current ORDER BY tag'
)->fetchAll(PDO::FETCH_ASSOC);

$result = array_map(static function (array $row): array {
    $age = (int) $row['age'];
    $quality = $row['quality'];
    if ($quality === 'good' && $age > STALE_AFTER_SECONDS) {
        // Il collector non aggiorna più il valore: lo si dichiara obsoleto
        $quality = 'stale';
    }
    return [
        'tag' => $row['tag'],
        'value' => $row['value'] === null ? null : (float) $row['value'],
        'quality' => $quality,
        'unit' => $row['unit'],
        'read_at' => $row['read_at'] . 'Z',
        'age_seconds' => $age,
    ];
}, $rows);

header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
echo json_encode(['tags' => $result], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);

L'utente del database usato dall'endpoint ha solo il permesso SELECT sulle due tabelle. Anche questa è una barriera: una vulnerabilità di SQL injection nel portale non potrebbe alterare i dati di processo.

E le scritture?

Il client implementa la funzione 6, scrittura di un singolo registro, ma il collector non la usa. Se il gestionale deve inviare un parametro all'impianto, per esempio il codice dell'ordine di produzione corrente, la richiesta deve passare dal database: l'applicazione web inserisce un comando in una tabella apposita, il collector lo legge, verifica che il tag sia nella lista dei registri scrivibili e che il valore rientri nei limiti, esegue la scrittura, rilegge il registro e registra l'esito. In questo modo il server web continua a non avere accesso alla rete industriale, ogni comando lascia una traccia e la logica di validazione sta in un unico punto.

Un'avvertenza: una scrittura fallita per timeout non va ripetuta automaticamente. Il PLC potrebbe averla eseguita prima che la risposta andasse persa. Il comando va marcato come esito incerto, verificato con una lettura e, se serve, ripresentato da un operatore.

Sicurezza

  • Modbus non ha autenticazione. Chi raggiunge la porta 502 di un PLC può scrivere qualsiasi registro. Il server del collector deve stare in una zona di rete dedicata, l'unica autorizzata a raggiungere i PLC, secondo il modello di segmentazione della IEC 62443.
  • Il server web non deve vedere l'impianto. La comunicazione passa solo dal database, che fa da zona di transizione.
  • Privilegi minimi sul database. Il collector scrive, il portale legge, nessuno dei due ha diritti amministrativi.
  • Nessun indirizzo nel codice applicativo. La mappa dei registri sta in un file di configurazione controllato: un errore di indirizzo in una scrittura può avere conseguenze fisiche.

Conclusioni

Implementare Modbus TCP a mano in PHP richiede meno di duecento righe e lascia una comprensione profonda del protocollo: il formato del frame MBAP, la gestione delle eccezioni, l'importanza degli identificativi di transazione, le insidie dell'ordine delle word. Ma la lezione più importante riguarda l'architettura: PHP funziona bene nell'integrazione con un sistema SCADA a patto di rispettarne il modello di esecuzione, con un processo di acquisizione persistente e un'applicazione web che non tocca mai direttamente l'impianto. Con questa separazione, il gestionale aziendale può mostrare i dati di produzione in tempo quasi reale senza diventare un punto di ingresso verso la rete industriale.