Interagire con i sistemi SCADA in Go
In un impianto industriale moderno i dati non restano più confinati nella sala controllo. Le letture dei PLC alimentano dashboard, sistemi di manutenzione predittiva, piattaforme di monitoraggio energetico. Il componente che rende possibile questo flusso è un servizio di raccolta dati che si colloca tra il livello SCADA e il resto dell'infrastruttura informatica: interroga i dispositivi di campo, normalizza i valori e li espone in un formato che gli strumenti IT comprendono.
Go si presta molto bene a questo ruolo. Un singolo binario statico si distribuisce senza dipendenze su un PC industriale o in un container; le goroutine permettono di interrogare decine di dispositivi in parallelo con un consumo di memoria contenuto; il pacchetto context offre un modo uniforme per gestire timeout e arresto ordinato. In questo articolo costruiremo un collector che legge più PLC via Modbus TCP, interroga un server OPC UA e pubblica tutto come metriche Prometheus, pronte per essere visualizzate in Grafana.
Il quadro di riferimento
Un sistema SCADA si articola su più livelli. In basso stanno sensori e attuatori; sopra, i controllori (PLC e RTU) che eseguono la logica di controllo in tempo reale; più in alto, i server SCADA e le HMI con cui gli operatori supervisionano il processo. Il nostro collector non sostituisce nessuno di questi elementi e soprattutto non partecipa al controllo: osserva, e solo in casi ben definiti scrive.
Parleremo due protocolli. Modbus TCP è semplice e onnipresente: il dispositivo espone tabelle di registri a 16 bit e di bit, raggiungibili per indirizzo numerico, senza alcuna informazione su tipo o significato. OPC UA è invece un modello a oggetti ricco, con nodi identificati da un NodeId, tipi di dato espliciti, timestamp e codici di stato per ogni valore. Molti impianti usano entrambi: Modbus per i dispositivi più semplici o datati, OPC UA come interfaccia dei PLC recenti o dei server SCADA stessi.
Struttura del progetto
Le dipendenze sono tre librerie consolidate: github.com/simonvetter/modbus per Modbus TCP, github.com/gopcua/opcua per OPC UA e il client ufficiale di Prometheus.
mkdir scada-collector && cd scada-collector
go mod init example.com/scada-collector
go get github.com/simonvetter/modbus
go get github.com/gopcua/opcua
go get github.com/prometheus/client_golang/prometheus
go get gopkg.in/yaml.v3
Il codice è organizzato in pochi file all'interno del pacchetto main, per restare leggibile: config.go per la configurazione, decode.go per la conversione dei registri, modbus_poller.go e opcua_poller.go per i due protocolli, metrics.go per l'esposizione e main.go per l'avvio.
Configurazione dichiarativa
Le variabili di processo, i tag, sono descritte in un file YAML. Ogni dispositivo ha il proprio indirizzo, il proprio intervallo di interrogazione e la propria lista di tag. Separare la mappa dei registri dal codice permette a chi conosce l'impianto di aggiornarla senza ricompilare.
listen: ":9105"
modbus_devices:
- name: boiler-plc
url: tcp://10.10.1.20:502
unit_id: 1
interval: 1s
timeout: 1500ms
tags:
- { name: water_temperature, table: holding, address: 100, type: int16, scale: 0.1 }
- { name: steam_pressure, table: holding, address: 102, type: float32, word_order: high_first }
- { name: burner_hours, table: input, address: 20, type: uint32, word_order: low_first }
- name: pump-station
url: tcp://10.10.1.30:502
unit_id: 3
interval: 2s
timeout: 1s
tags:
- { name: flow_rate, table: input, address: 0, type: float32, word_order: high_first }
- { name: pump_running, table: coil, address: 5, type: bool }
opcua_servers:
- name: line-controller
endpoint: opc.tcp://10.10.2.10:4840
interval: 1s
nodes:
- { name: conveyor_speed, node_id: "ns=2;s=Line1.Conveyor.Speed" }
- { name: reject_count, node_id: "ns=2;s=Line1.Quality.Rejects" }
// config.go
package main
import (
"fmt"
"os"
"time"
"gopkg.in/yaml.v3"
)
type Tag struct {
Name string `yaml:"name"`
Table string `yaml:"table"`
Address uint16 `yaml:"address"`
Type string `yaml:"type"`
WordOrder string `yaml:"word_order"`
Scale float64 `yaml:"scale"`
}
type ModbusDevice struct {
Name string `yaml:"name"`
URL string `yaml:"url"`
UnitID uint8 `yaml:"unit_id"`
Interval time.Duration `yaml:"interval"`
Timeout time.Duration `yaml:"timeout"`
Tags []Tag `yaml:"tags"`
}
type OpcUaNode struct {
Name string `yaml:"name"`
NodeID string `yaml:"node_id"`
}
type OpcUaServer struct {
Name string `yaml:"name"`
Endpoint string `yaml:"endpoint"`
Interval time.Duration `yaml:"interval"`
Nodes []OpcUaNode `yaml:"nodes"`
}
type Config struct {
Listen string `yaml:"listen"`
ModbusDevices []ModbusDevice `yaml:"modbus_devices"`
OpcUaServers []OpcUaServer `yaml:"opcua_servers"`
}
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("lettura configurazione: %w", err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing configurazione: %w", err)
}
// Valida i tag prima di avviare qualsiasi connessione
for _, device := range cfg.ModbusDevices {
for _, tag := range device.Tags {
if _, err := registerCount(tag.Type); err != nil {
return nil, fmt.Errorf("dispositivo %s, tag %s: %w", device.Name, tag.Name, err)
}
}
}
return &cfg, nil
}
Il pacchetto yaml.v3 converte automaticamente stringhe come 1500ms in time.Duration, il che rende la configurazione leggibile. La validazione all'avvio è importante: è molto meglio che il servizio rifiuti di partire con un tipo sconosciuto piuttosto che scoprire l'errore alle tre di notte.
Dalla parola di 16 bit al valore ingegneristico
Modbus trasporta parole di 16 bit in ordine big-endian. Per i tipi a 32 bit servono due registri, e la specifica non dice quale dei due contenga la parte alta: dipende dal costruttore. La libreria che usiamo offre funzioni pronte come ReadFloat32, ma conviene implementare la decodifica a mano, perché così un unico blocco di registri letto in una sola richiesta può servire più tag di tipi diversi.
// decode.go
package main
import (
"encoding/binary"
"fmt"
"math"
)
func registerCount(dataType string) (uint16, error) {
switch dataType {
case "bool", "int16", "uint16":
return 1, nil
case "int32", "uint32", "float32":
return 2, nil
default:
return 0, fmt.Errorf("tipo non supportato: %q", dataType)
}
}
// decodeRegisters converte una sequenza di registri nel valore numerico del tag
func decodeRegisters(registers []uint16, tag Tag) (float64, error) {
count, err := registerCount(tag.Type)
if err != nil {
return 0, err
}
if len(registers) < int(count) {
return 0, fmt.Errorf("registri insufficienti per %s: %d", tag.Name, len(registers))
}
buf := make([]byte, count*2)
for i := 0; i < int(count); i++ {
word := registers[i]
// Con low_first la word meno significativa occupa il primo registro
position := i
if tag.WordOrder == "low_first" {
position = int(count) - 1 - i
}
binary.BigEndian.PutUint16(buf[position*2:], word)
}
var raw float64
switch tag.Type {
case "int16":
raw = float64(int16(binary.BigEndian.Uint16(buf)))
case "uint16":
raw = float64(binary.BigEndian.Uint16(buf))
case "int32":
raw = float64(int32(binary.BigEndian.Uint32(buf)))
case "uint32":
raw = float64(binary.BigEndian.Uint32(buf))
case "float32":
value := math.Float32frombits(binary.BigEndian.Uint32(buf))
if math.IsNaN(float64(value)) || math.IsInf(float64(value), 0) {
return 0, fmt.Errorf("valore float non valido per %s", tag.Name)
}
raw = float64(value)
}
if tag.Scale != 0 {
// Arrotonda per eliminare gli artefatti della virgola mobile (21.700000000000003)
raw = math.Round(raw*tag.Scale*1e6) / 1e6
}
return raw, nil
}
Il controllo su NaN e infinito non è teorico. Se l'ordine delle word è configurato al contrario, alcuni valori producono sequenze di bit che corrispondono a NaN, e un NaN che finisce in Prometheus rende inutilizzabili aggregazioni e allarmi. Meglio scartarlo e segnalarlo come lettura di cattiva qualità.
Le metriche
Prometheus è una scelta pragmatica: lo scraping è pull, quindi il collector non deve conoscere dove spedire i dati, e Grafana sa già leggerlo. Esporremo tre famiglie di metriche: il valore di ogni tag, la sua qualità (1 se l'ultima lettura è riuscita, 0 altrimenti) e un contatore degli errori per dispositivo.
// metrics.go
package main
import "github.com/prometheus/client_golang/prometheus"
type Metrics struct {
value *prometheus.GaugeVec
quality *prometheus.GaugeVec
errors *prometheus.CounterVec
duration *prometheus.HistogramVec
}
func NewMetrics(registry *prometheus.Registry) *Metrics {
m := &Metrics{
value: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "scada_tag_value",
Help: "Ultimo valore letto per ciascun tag",
}, []string{"source", "tag"}),
quality: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "scada_tag_quality",
Help: "Qualità dell'ultima lettura: 1 buona, 0 non valida",
}, []string{"source", "tag"}),
errors: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "scada_poll_errors_total",
Help: "Numero di cicli di lettura falliti",
}, []string{"source"}),
duration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "scada_poll_duration_seconds",
Help: "Durata di un ciclo di lettura completo",
Buckets: []float64{0.01, 0.05, 0.1, 0.25, 0.5, 1, 2},
}, []string{"source"}),
}
registry.MustRegister(m.value, m.quality, m.errors, m.duration)
return m
}
func (m *Metrics) Good(source, tag string, value float64) {
m.value.WithLabelValues(source, tag).Set(value)
m.quality.WithLabelValues(source, tag).Set(1)
}
func (m *Metrics) Bad(source, tag string) {
// Il valore precedente resta esposto, ma la qualità segnala che non è attuale
m.quality.WithLabelValues(source, tag).Set(0)
}
Separare valore e qualità è una decisione deliberata. Se un PLC diventa irraggiungibile, cancellare la metrica del valore farebbe sparire la serie dai grafici, mentre lasciarla senza indicazioni farebbe credere che il valore sia ancora valido. Con la metrica di qualità a zero, una regola di allarme come scada_tag_quality == 0 diventa immediata.
Il poller Modbus
Ogni dispositivo ha la propria goroutine, con un ciclo governato da un time.Ticker. Le letture avvengono in blocchi: i tag della stessa tabella vengono ordinati per indirizzo e raggruppati in intervalli contigui fino al limite di 125 registri per richiesta. Se un ciclo fallisce, la connessione viene chiusa e riaperta con un ritardo esponenziale.
// modbus_poller.go
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"sort"
"time"
"github.com/simonvetter/modbus"
)
const maxRegistersPerRead = 125
type block struct {
table string
start uint16
end uint16
tags []Tag
}
func planBlocks(tags []Tag) []block {
byTable := map[string][]Tag{}
for _, tag := range tags {
byTable[tag.Table] = append(byTable[tag.Table], tag)
}
var blocks []block
for table, list := range byTable {
sort.Slice(list, func(i, j int) bool { return list[i].Address < list[j].Address })
var current *block
for _, tag := range list {
count, _ := registerCount(tag.Type)
end := tag.Address + count
if current != nil && tag.Address <= current.end+4 && end-current.start <= maxRegistersPerRead {
if end > current.end {
current.end = end
}
current.tags = append(current.tags, tag)
continue
}
blocks = append(blocks, block{table: table, start: tag.Address, end: end, tags: []Tag{tag}})
current = &blocks[len(blocks)-1]
}
}
return blocks
}
type ModbusPoller struct {
device ModbusDevice
metrics *Metrics
logger *slog.Logger
blocks []block
client *modbus.ModbusClient
}
func NewModbusPoller(device ModbusDevice, metrics *Metrics, logger *slog.Logger) *ModbusPoller {
return &ModbusPoller{
device: device,
metrics: metrics,
logger: logger.With("device", device.Name),
blocks: planBlocks(device.Tags),
}
}
func (p *ModbusPoller) connect() error {
client, err := modbus.NewClient(&modbus.ClientConfiguration{
URL: p.device.URL,
Timeout: p.device.Timeout,
})
if err != nil {
return fmt.Errorf("creazione client: %w", err)
}
if err := client.Open(); err != nil {
return fmt.Errorf("apertura connessione: %w", err)
}
if err := client.SetUnitId(p.device.UnitID); err != nil {
client.Close()
return fmt.Errorf("impostazione unit id: %w", err)
}
p.client = client
p.logger.Info("connesso", "url", p.device.URL)
return nil
}
func (p *ModbusPoller) disconnect() {
if p.client != nil {
p.client.Close()
p.client = nil
}
}
func (p *ModbusPoller) Run(ctx context.Context) {
defer p.disconnect()
backoff := time.Second
ticker := time.NewTicker(p.device.Interval)
defer ticker.Stop()
for {
if p.client == nil {
if err := p.connect(); err != nil {
p.logger.Warn("connessione fallita", "error", err, "retry_in", backoff)
p.markAllBad()
if !sleepContext(ctx, backoff) {
return
}
backoff = min(backoff*2, 30*time.Second)
continue
}
backoff = time.Second
}
started := time.Now()
if err := p.poll(); err != nil {
p.metrics.errors.WithLabelValues(p.device.Name).Inc()
p.logger.Warn("ciclo di lettura fallito", "error", err)
p.markAllBad()
// Un'eccezione Modbus indica un problema di mappa, non di rete: la connessione resta valida
if !isModbusException(err) {
p.disconnect()
}
}
p.metrics.duration.WithLabelValues(p.device.Name).Observe(time.Since(started).Seconds())
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (p *ModbusPoller) poll() error {
for _, b := range p.blocks {
quantity := b.end - b.start
var registers []uint16
var err error
switch b.table {
case "holding":
registers, err = p.client.ReadRegisters(b.start, quantity, modbus.HOLDING_REGISTER)
case "input":
registers, err = p.client.ReadRegisters(b.start, quantity, modbus.INPUT_REGISTER)
case "coil":
var bits []bool
bits, err = p.client.ReadCoils(b.start, quantity)
registers = boolsToRegisters(bits)
default:
return fmt.Errorf("tabella sconosciuta: %s", b.table)
}
if err != nil {
return fmt.Errorf("lettura %s da %d (%d registri): %w", b.table, b.start, quantity, err)
}
for _, tag := range b.tags {
offset := tag.Address - b.start
if tag.Type == "bool" {
p.metrics.Good(p.device.Name, tag.Name, float64(registers[offset]))
continue
}
value, err := decodeRegisters(registers[offset:], tag)
if err != nil {
p.logger.Warn("decodifica fallita", "tag", tag.Name, "error", err)
p.metrics.Bad(p.device.Name, tag.Name)
continue
}
p.metrics.Good(p.device.Name, tag.Name, value)
}
}
return nil
}
func (p *ModbusPoller) markAllBad() {
for _, tag := range p.device.Tags {
p.metrics.Bad(p.device.Name, tag.Name)
}
}
func boolsToRegisters(bits []bool) []uint16 {
out := make([]uint16, len(bits))
for i, bit := range bits {
if bit {
out[i] = 1
}
}
return out
}
func isModbusException(err error) bool {
return errors.Is(err, modbus.ErrIllegalFunction) ||
errors.Is(err, modbus.ErrIllegalDataAddress) ||
errors.Is(err, modbus.ErrIllegalDataValue)
}
func sleepContext(ctx context.Context, d time.Duration) bool {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}
La distinzione operata da isModbusException è utile. Se il PLC risponde con Illegal Data Address, la rete funziona e il dispositivo è vivo: il problema è nella mappa dei registri, e chiudere la connessione servirebbe solo a sovraccaricarlo di riconnessioni. Un timeout o un errore di socket, al contrario, richiedono di ripartire da una connessione pulita, perché il flusso TCP potrebbe essere desincronizzato e la risposta successiva potrebbe appartenere alla richiesta precedente.
Il time.Ticker ha un comportamento che vale la pena ricordare: se un ciclo dura più dell'intervallo, il tick perso non si accumula, perché il canale ha capacità uno. Questo impedisce che un dispositivo lento venga bombardato di richieste arretrate.
Il poller OPC UA
OPC UA consente sia le sottoscrizioni sia la lettura su richiesta. Per un collector che espone metriche con un intervallo fisso, la lettura periodica tramite il servizio Read è più semplice e altrettanto efficiente, perché tutti i nodi viaggiano in un'unica richiesta. Le sottoscrizioni diventano preferibili quando servono tutte le variazioni intermedie o quando il numero di nodi è molto grande.
// opcua_poller.go
package main
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/gopcua/opcua"
"github.com/gopcua/opcua/ua"
)
type OpcUaPoller struct {
server OpcUaServer
metrics *Metrics
logger *slog.Logger
nodeIDs []*ua.NodeID
}
func NewOpcUaPoller(server OpcUaServer, metrics *Metrics, logger *slog.Logger) (*OpcUaPoller, error) {
ids := make([]*ua.NodeID, 0, len(server.Nodes))
for _, node := range server.Nodes {
id, err := ua.ParseNodeID(node.NodeID)
if err != nil {
return nil, fmt.Errorf("NodeId non valido %q: %w", node.NodeID, err)
}
ids = append(ids, id)
}
return &OpcUaPoller{server: server, metrics: metrics, logger: logger.With("server", server.Name), nodeIDs: ids}, nil
}
func (p *OpcUaPoller) Run(ctx context.Context) {
backoff := time.Second
for ctx.Err() == nil {
if err := p.session(ctx); err != nil {
p.logger.Warn("sessione OPC UA terminata", "error", err, "retry_in", backoff)
p.markAllBad()
if !sleepContext(ctx, backoff) {
return
}
backoff = min(backoff*2, 30*time.Second)
continue
}
backoff = time.Second
}
}
func (p *OpcUaPoller) session(ctx context.Context) error {
client, err := opcua.NewClient(p.server.Endpoint,
// In produzione: SecurityPolicy Basic256Sha256 e modalità SignAndEncrypt
opcua.SecurityMode(ua.MessageSecurityModeNone),
opcua.SecurityPolicy("None"),
opcua.RequestTimeout(5*time.Second),
)
if err != nil {
return fmt.Errorf("creazione client: %w", err)
}
if err := client.Connect(ctx); err != nil {
return fmt.Errorf("connessione: %w", err)
}
defer client.Close(context.Background())
p.logger.Info("connesso", "endpoint", p.server.Endpoint)
request := &ua.ReadRequest{
MaxAge: 0,
TimestampsToReturn: ua.TimestampsToReturnBoth,
NodesToRead: make([]*ua.ReadValueID, len(p.nodeIDs)),
}
for i, id := range p.nodeIDs {
request.NodesToRead[i] = &ua.ReadValueID{NodeID: id, AttributeID: ua.AttributeIDValue}
}
ticker := time.NewTicker(p.server.Interval)
defer ticker.Stop()
for {
response, err := client.Read(ctx, request)
if err != nil {
p.metrics.errors.WithLabelValues(p.server.Name).Inc()
return fmt.Errorf("lettura: %w", err)
}
if len(response.Results) != len(p.server.Nodes) {
return fmt.Errorf("risposta con %d risultati invece di %d", len(response.Results), len(p.server.Nodes))
}
for i, result := range response.Results {
name := p.server.Nodes[i].Name
// Ogni nodo ha il proprio codice di stato, indipendente dall'esito della richiesta
if result.Status != ua.StatusOK || result.Value == nil {
p.metrics.Bad(p.server.Name, name)
continue
}
value, ok := toFloat(result.Value.Value())
if !ok {
p.logger.Warn("tipo non numerico", "node", name)
p.metrics.Bad(p.server.Name, name)
continue
}
p.metrics.Good(p.server.Name, name, value)
}
select {
case <-ctx.Done():
return nil
case <-ticker.C:
}
}
}
func (p *OpcUaPoller) markAllBad() {
for _, node := range p.server.Nodes {
p.metrics.Bad(p.server.Name, node.Name)
}
}
func toFloat(v any) (float64, bool) {
switch n := v.(type) {
case float64:
return n, true
case float32:
return float64(n), true
case int16:
return float64(n), true
case uint16:
return float64(n), true
case int32:
return float64(n), true
case uint32:
return float64(n), true
case int64:
return float64(n), true
case uint64:
return float64(n), true
case bool:
if n {
return 1, true
}
return 0, true
default:
return 0, false
}
}
Il controllo sul singolo result.Status è essenziale e spesso dimenticato. Una richiesta Read può riuscire nel suo complesso mentre uno dei nodi restituisce BadNodeIdUnknown, per esempio perché il programma del PLC è stato modificato e la variabile rinominata. Senza questo controllo il collector esporrebbe zeri perfettamente plausibili.
Avvio e arresto ordinato
Il main carica la configurazione, crea il registro delle metriche, avvia una goroutine per ogni sorgente e un server HTTP per lo scraping. signal.NotifyContext trasforma SIGINT e SIGTERM nella cancellazione del contesto, che si propaga a tutti i poller.
// main.go
package main
import (
"context"
"errors"
"flag"
"log/slog"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
func main() {
configPath := flag.String("config", "collector.yaml", "percorso del file di configurazione")
flag.Parse()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
cfg, err := LoadConfig(*configPath)
if err != nil {
logger.Error("configurazione non valida", "error", err)
os.Exit(1)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
registry := prometheus.NewRegistry()
metrics := NewMetrics(registry)
var wg sync.WaitGroup
for _, device := range cfg.ModbusDevices {
poller := NewModbusPoller(device, metrics, logger)
wg.Go(func() { poller.Run(ctx) })
}
for _, server := range cfg.OpcUaServers {
poller, err := NewOpcUaPoller(server, metrics, logger)
if err != nil {
logger.Error("server OPC UA non valido", "server", server.Name, "error", err)
os.Exit(1)
}
wg.Go(func() { poller.Run(ctx) })
}
mux := http.NewServeMux()
mux.Handle("/metrics", promhttp.HandlerFor(registry, promhttp.HandlerOpts{}))
httpServer := &http.Server{Addr: cfg.Listen, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
go func() {
logger.Info("endpoint metriche attivo", "addr", cfg.Listen)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("server HTTP terminato", "error", err)
stop()
}
}()
<-ctx.Done()
logger.Info("arresto in corso")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = httpServer.Shutdown(shutdownCtx)
wg.Wait()
logger.Info("collector arrestato")
}
Il metodo WaitGroup.Go è disponibile da Go 1.25 e sostituisce la coppia Add e Done; con versioni precedenti basta tornare alla forma classica. Anche la funzione predefinita min usata nel calcolo del backoff richiede Go 1.21 o successivo. Dal momento che dalla 1.22 la variabile di un ciclo for è nuova a ogni iterazione, le closure possono catturare poller senza copie esplicite.
Scrivere verso l'impianto
Il collector così com'è non scrive nulla, ed è la configurazione più sicura. Se serve inviare un setpoint, la scrittura va trattata come un'operazione a parte, con regole più severe delle letture.
// write.go
package main
import (
"fmt"
"math"
"github.com/simonvetter/modbus"
)
type WritableTag struct {
Tag
Min float64
Max float64
}
// WriteSetpoint scrive un valore int16 scalato dopo aver verificato i limiti e lo rilegge
func (p *ModbusPoller) WriteSetpoint(target WritableTag, value float64) (float64, error) {
if p.client == nil {
return 0, fmt.Errorf("dispositivo %s non connesso", p.device.Name)
}
if math.IsNaN(value) || value < target.Min || value > target.Max {
return 0, fmt.Errorf("valore %.3f fuori dai limiti [%.3f, %.3f]", value, target.Min, target.Max)
}
if target.Type != "int16" || target.Table != "holding" {
return 0, fmt.Errorf("scrittura supportata solo per holding register int16")
}
scale := target.Scale
if scale == 0 {
scale = 1
}
raw := math.Round(value / scale)
if raw < math.MinInt16 || raw > math.MaxInt16 {
return 0, fmt.Errorf("valore grezzo %.0f fuori dall'intervallo int16", raw)
}
// Nessun nuovo tentativo automatico: l'esito di una scrittura fallita è sconosciuto
if err := p.client.WriteRegister(target.Address, uint16(int16(raw))); err != nil {
return 0, fmt.Errorf("scrittura: %w", err)
}
readBack, err := p.client.ReadRegister(target.Address, modbus.HOLDING_REGISTER)
if err != nil {
return 0, fmt.Errorf("rilettura: %w", err)
}
confirmed := float64(int16(readBack)) * scale
if math.Abs(confirmed-value) > scale/2 {
return confirmed, fmt.Errorf("il PLC non ha accettato il valore: richiesto %.3f, attivo %.3f", value, confirmed)
}
return confirmed, nil
}
La tolleranza di mezzo passo di scala nel confronto finale assorbe l'arrotondamento introdotto dalla conversione in valore grezzo. Va inoltre ricordato che il client Modbus non è pensato per essere usato da più goroutine contemporaneamente: se la scrittura viene invocata da un handler HTTP mentre il poller sta leggendo, occorre proteggere p.client con un sync.Mutex oppure, soluzione più pulita, far passare le richieste di scrittura attraverso un canale letto dalla stessa goroutine del poller. In questo modo tutte le operazioni sul dispositivo restano serializzate per costruzione.
Considerazioni di sicurezza
- Modbus TCP non ha autenticazione né cifratura. Il collector deve stare in una zona di rete dedicata, separata sia dall'impianto sia dalla rete aziendale, secondo il modello a zone e condotti della IEC 62443. L'endpoint
/metricsva esposto solo verso il server Prometheus. - Sola lettura come impostazione predefinita. Se un componente non deve scrivere, il modo migliore per garantirlo è che il codice di scrittura non esista o non sia raggiungibile. Molti PLC permettono anche di limitare a livello di configurazione quali indirizzi IP possono scrivere.
- OPC UA con sicurezza attiva. In produzione la modalità
Noneva sostituita daSignAndEncrypt, con un certificato client che l'amministratore del server OPC UA deve accettare esplicitamente, e con un utente dotato dei soli permessi di lettura. - Carico sui dispositivi. Un intervallo di interrogazione troppo aggressivo può rallentare il ciclo di scansione di un PLC datato. Un secondo è un buon punto di partenza; scendere sotto i cento millisecondi va concordato con chi gestisce l'impianto.
Conclusioni
In poche centinaia di righe abbiamo ottenuto un collector che gestisce più dispositivi in parallelo, raggruppa le letture per ridurre il carico, distingue gli errori di rete da quelli di mappa, segnala la qualità di ogni valore e si arresta in modo ordinato. Il modello a goroutine con un poller per sorgente scala senza sforzo a decine di dispositivi, e il binario risultante si distribuisce con la stessa facilità su un PC industriale o in un cluster. Da qui si possono aggiungere facilmente nuovi protocolli, una cache su disco per non perdere dati durante le interruzioni di rete o l'esportazione verso un broker MQTT: la struttura resta la stessa.