Interagire con i sistemi SCADA in C#

Interagire con i sistemi SCADA in C#

L'ecosistema .NET è di casa negli stabilimenti industriali. Molte HMI e molti strumenti di configurazione dei PLC girano su Windows, le applicazioni di reparto sono spesso scritte in C#, e con .NET moderno lo stesso codice gira indifferentemente su un PC industriale Windows, su un gateway Linux o in un container. Per chi deve integrare un impianto con il resto dell'infrastruttura aziendale, C# offre un modello di servizio di lunga durata maturo, con dependency injection, configurazione, logging e health check già pronti.

In questo articolo costruiremo un servizio di acquisizione con ASP.NET Core: un BackgroundService legge i PLC tramite Modbus TCP usando la libreria NModbus, i valori confluiscono in una cache condivisa tramite un Channel, e una piccola API HTTP espone lo stato dei tag e permette, con regole precise, di inviare setpoint. Chiuderemo con una panoramica su come estendere il servizio a OPC UA.

Dove si colloca il servizio

Un sistema SCADA è composto da più livelli: i PLC che eseguono la logica di controllo in tempo reale, i server SCADA che raccolgono i dati e gestiscono allarmi e storico, le HMI degli operatori. Il nostro servizio è un consumatore aggiuntivo che porta i dati verso applicazioni aziendali come un MES, un portale di reparto o un sistema di manutenzione. Non sostituisce lo SCADA e non esegue logica di controllo: quella resta nel PLC, che è progettato per garantirla anche quando la rete o i server si fermano.

Modbus TCP, il protocollo che useremo, è il denominatore comune dei dispositivi industriali. Il modello dei dati è minimale: coil e discrete input sono bit, holding register e input register sono parole di 16 bit, e ogni elemento è individuato solo da un indirizzo numerico. Il significato di un registro, il suo tipo, la scala e l'unità di misura sono informazioni che si trovano nella documentazione del PLC e che dobbiamo codificare nella configurazione del servizio.

Creare il progetto

Partiamo da un progetto web vuoto con .NET 10 e aggiungiamo NModbus.

dotnet new web -n ScadaService
cd ScadaService
dotnet add package NModbus --version 3.0.83

La configurazione dei tag

La mappa dei registri vive in appsettings.json e viene collegata a classi tipizzate tramite il pattern options. Ogni dispositivo ha indirizzo, unit identifier, intervallo di lettura e lista di tag; ogni tag specifica tabella, indirizzo a base zero, tipo, ordine delle word, scala e, se è scrivibile, i limiti ammessi.

{
  "Logging": { "LogLevel": { "Default": "Information" } },
  "Scada": {
    "Devices": [
      {
        "Name": "extruder",
        "Host": "10.10.1.40",
        "Port": 502,
        "UnitId": 1,
        "PollIntervalMs": 1000,
        "TimeoutMs": 1500,
        "Tags": [
          { "Name": "barrel.zone1", "Table": "Holding", "Address": 200, "Type": "Int16", "Scale": 0.1, "Unit": "°C" },
          { "Name": "barrel.zone1.setpoint", "Table": "Holding", "Address": 201, "Type": "Int16", "Scale": 0.1, "Unit": "°C",
            "Writable": true, "Min": 150, "Max": 260 },
          { "Name": "screw.speed", "Table": "Holding", "Address": 210, "Type": "Float32", "WordOrder": "HighFirst", "Unit": "rpm" },
          { "Name": "output.meters", "Table": "Input", "Address": 0, "Type": "UInt32", "WordOrder": "LowFirst", "Unit": "m" }
        ]
      }
    ]
  }
}
// Configuration/ScadaOptions.cs
namespace ScadaService.Configuration;

public enum RegisterTable { Holding, Input }

public enum RegisterType { Int16, UInt16, Int32, UInt32, Float32 }

public enum WordOrder { HighFirst, LowFirst }

public sealed class TagOptions
{
    public required string Name { get; init; }
    public RegisterTable Table { get; init; }
    public ushort Address { get; init; }
    public RegisterType Type { get; init; }
    public WordOrder WordOrder { get; init; } = WordOrder.HighFirst;
    public double Scale { get; init; } = 1.0;
    public string Unit { get; init; } = "";
    public bool Writable { get; init; }
    public double? Min { get; init; }
    public double? Max { get; init; }

    public ushort RegisterCount => Type is RegisterType.Int16 or RegisterType.UInt16 ? (ushort)1 : (ushort)2;
}

public sealed class DeviceOptions
{
    public required string Name { get; init; }
    public required string Host { get; init; }
    public int Port { get; init; } = 502;
    public byte UnitId { get; init; } = 1;
    public int PollIntervalMs { get; init; } = 1000;
    public int TimeoutMs { get; init; } = 1500;
    public List<TagOptions> Tags { get; init; } = [];
}

public sealed class ScadaOptions
{
    public const string SectionName = "Scada";
    public List<DeviceOptions> Devices { get; init; } = [];
}

L'indirizzo indicato è l'offset a base zero trasmesso sul filo. Se il manuale del PLC usa la notazione storica, come 40201, l'offset corrispondente è 200: la prima cifra identifica la tabella e la numerazione parte da uno.

Dai registri ai valori ingegneristici

NModbus restituisce un array di ushort, uno per registro. Per i tipi a 16 bit basta una conversione, per quelli a 32 bit bisogna combinare due registri nell'ordine giusto, che varia da costruttore a costruttore. BinaryPrimitives permette di farlo senza allocazioni e senza dipendere dall'ordine dei byte della macchina che esegue il codice.

// Modbus/RegisterDecoder.cs
using System.Buffers.Binary;
using ScadaService.Configuration;

namespace ScadaService.Modbus;

public static class RegisterDecoder
{
    public static double Decode(ReadOnlySpan<ushort> registers, TagOptions tag)
    {
        if (registers.Length < tag.RegisterCount)
        {
            throw new ArgumentException($"Registri insufficienti per {tag.Name}");
        }

        Span<byte> bytes = stackalloc byte[4];
        if (tag.RegisterCount == 1)
        {
            BinaryPrimitives.WriteUInt16BigEndian(bytes, registers[0]);
        }
        else
        {
            // La word alta deve finire nei primi due byte per una lettura big-endian
            var (high, low) = tag.WordOrder == WordOrder.HighFirst
                ? (registers[0], registers[1])
                : (registers[1], registers[0]);
            BinaryPrimitives.WriteUInt16BigEndian(bytes, high);
            BinaryPrimitives.WriteUInt16BigEndian(bytes[2..], low);
        }

        double raw = tag.Type switch
        {
            RegisterType.Int16 => BinaryPrimitives.ReadInt16BigEndian(bytes),
            RegisterType.UInt16 => BinaryPrimitives.ReadUInt16BigEndian(bytes),
            RegisterType.Int32 => BinaryPrimitives.ReadInt32BigEndian(bytes),
            RegisterType.UInt32 => BinaryPrimitives.ReadUInt32BigEndian(bytes),
            RegisterType.Float32 => ReadFiniteSingle(bytes, tag.Name),
            _ => throw new NotSupportedException(tag.Type.ToString()),
        };
        return Math.Round(raw * tag.Scale, 6);
    }

    public static ushort EncodeInt16(double engineeringValue, TagOptions tag)
    {
        double raw = Math.Round(engineeringValue / tag.Scale);
        if (raw is < short.MinValue or > short.MaxValue)
        {
            throw new ArgumentOutOfRangeException(nameof(engineeringValue), "Valore grezzo fuori dall'intervallo Int16");
        }
        // Il registro trasporta i 16 bit del complemento a due
        return unchecked((ushort)(short)raw);
    }

    private static float ReadFiniteSingle(ReadOnlySpan<byte> bytes, string tagName)
    {
        float value = BinaryPrimitives.ReadSingleBigEndian(bytes);
        if (!float.IsFinite(value))
        {
            throw new InvalidDataException($"Float non valido per {tagName}: verificare l'ordine delle word");
        }
        return value;
    }
}

Il controllo sui float non finiti ha una ragione pratica. Quando l'ordine delle word è configurato al contrario, alcuni valori producono sequenze di bit che corrispondono a NaN o a infinito; altri producono numeri plausibili ma sbagliati, come 1,2 per dieci alla meno trentottesima. Il primo caso almeno si individua automaticamente, e conviene marcarlo come lettura non valida invece di propagarlo.

La cache dei tag e il canale delle letture

I valori letti viaggiano su un Channel, una coda asincrona ad alte prestazioni della libreria standard. Un servizio dedicato consuma il canale e aggiorna la cache, che l'API interroga. Separare produttori e consumatore in questo modo ha due vantaggi: i poller non si bloccano mai su un lock condiviso con l'API, e aggiungere un secondo consumatore, per esempio un writer verso un database di serie temporali, non richiede di modificare i poller.

// Tags/TagReading.cs
namespace ScadaService.Tags;

public enum Quality { Good, Bad }

public sealed record TagReading(string Device, string Name, double? Value, Quality Quality, DateTimeOffset Timestamp, string Unit)
{
    public static TagReading Bad(string device, string name, string unit) =>
        new(device, name, null, Quality.Bad, DateTimeOffset.UtcNow, unit);
}
// Tags/TagCache.cs
using System.Collections.Concurrent;
using System.Threading.Channels;

namespace ScadaService.Tags;

public sealed class TagCache
{
    private readonly ConcurrentDictionary<string, TagReading> _current = new();

    // Canale limitato: se il consumatore resta indietro, si scartano le letture più vecchie
    public Channel<TagReading> Readings { get; } = Channel.CreateBounded<TagReading>(
        new BoundedChannelOptions(10_000) { FullMode = BoundedChannelFullMode.DropOldest, SingleReader = true });

    public void Apply(TagReading reading) => _current[reading.Name] = reading;

    public TagReading? Get(string name) => _current.GetValueOrDefault(name);

    public IReadOnlyCollection<TagReading> Snapshot() => _current.Values.OrderBy(r => r.Name).ToList();
}

public sealed class TagCacheUpdater(TagCache cache, ILogger<TagCacheUpdater> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var reading in cache.Readings.Reader.ReadAllAsync(stoppingToken))
        {
            var previous = cache.Get(reading.Name);
            cache.Apply(reading);
            if (previous is not null && previous.Quality != reading.Quality)
            {
                logger.LogWarning("Qualità del tag {Tag} passata da {From} a {To}", reading.Name, previous.Quality, reading.Quality);
            }
        }
    }
}

Il poller Modbus

Per ogni dispositivo configurato viene creato un DevicePoller. Il poller apre una connessione TCP, crea il master Modbus con la factory di NModbus, legge i tag raggruppati in blocchi contigui e pubblica i valori nel canale. Usa un PeriodicTimer, che a differenza di System.Threading.Timer non sovrappone mai due esecuzioni: se un ciclo dura più dell'intervallo, il tick successivo arriva subito dopo, ma non si accumulano letture concorrenti.

Tutte le operazioni sul dispositivo, letture e scritture, sono protette da un SemaphoreSlim. Su una connessione Modbus TCP una richiesta deve completarsi prima che inizi la successiva: due richieste intrecciate produrrebbero risposte associate alla richiesta sbagliata.

// Modbus/DevicePoller.cs
using System.Net.Sockets;
using NModbus;
using ScadaService.Configuration;
using ScadaService.Tags;

namespace ScadaService.Modbus;

public sealed class DevicePoller(DeviceOptions device, TagCache cache, ILogger logger) : IAsyncDisposable
{
    private const int MaxRegistersPerRead = 125;
    private readonly ModbusFactory _factory = new();
    private readonly SemaphoreSlim _gate = new(1, 1);
    private readonly IReadOnlyList<Block> _blocks = PlanBlocks(device.Tags);
    private TcpClient? _tcp;
    private IModbusMaster? _master;

    public DeviceOptions Device => device;

    private sealed record Block(RegisterTable Table, ushort Start, ushort Count, IReadOnlyList<TagOptions> Tags);

    public async Task RunAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(device.PollIntervalMs));
        var backoff = TimeSpan.FromSeconds(1);

        do
        {
            try
            {
                await _gate.WaitAsync(stoppingToken);
                try
                {
                    await EnsureConnectedAsync(stoppingToken);
                    await PollAsync();
                }
                finally
                {
                    _gate.Release();
                }
                backoff = TimeSpan.FromSeconds(1);
            }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                break;
            }
            catch (SlaveException ex)
            {
                // Il dispositivo ha risposto con un'eccezione: la connessione è valida, la mappa no
                logger.LogError("Eccezione Modbus {Code} da {Device}: {Message}", ex.SlaveExceptionCode, device.Name, ex.Message);
                await PublishAllBadAsync();
            }
            catch (Exception ex) when (ex is IOException or SocketException or TimeoutException
                                           or InvalidOperationException or OperationCanceledException)
            {
                // Un OperationCanceledException qui è il timeout di connessione, non l'arresto del servizio
                logger.LogWarning("Comunicazione con {Device} fallita: {Message}. Nuovo tentativo tra {Delay}", device.Name, ex.Message, backoff);
                Disconnect();
                await PublishAllBadAsync();
                await Task.Delay(backoff, stoppingToken).ContinueWith(_ => { }, TaskScheduler.Default);
                backoff = TimeSpan.FromSeconds(Math.Min(backoff.TotalSeconds * 2, 30));
            }
            catch (Exception ex)
            {
                // Qualsiasi altro errore non deve terminare il poller, e con esso l'intero host
                logger.LogError(ex, "Errore inatteso nel ciclo di {Device}", device.Name);
                Disconnect();
                await PublishAllBadAsync();
            }
        }
        while (await timer.WaitForNextTickAsync(stoppingToken).AsTask().ContinueWith(t => !t.IsCanceled && t.Result, TaskScheduler.Default));
    }

    private async Task EnsureConnectedAsync(CancellationToken cancellationToken)
    {
        if (_master is not null && _tcp is { Connected: true })
        {
            return;
        }
        Disconnect();

        var tcp = new TcpClient();
        try
        {
            using var connectTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
            connectTimeout.CancelAfter(device.TimeoutMs * 2);
            await tcp.ConnectAsync(device.Host, device.Port, connectTimeout.Token);
        }
        catch
        {
            tcp.Dispose();
            throw;
        }

        var master = _factory.CreateMaster(tcp);
        master.Transport.ReadTimeout = device.TimeoutMs;
        master.Transport.WriteTimeout = device.TimeoutMs;
        // Nessun nuovo tentativo interno: la politica di retry la decide il servizio
        master.Transport.Retries = 0;

        _tcp = tcp;
        _master = master;
        logger.LogInformation("Connesso a {Device} ({Host}:{Port})", device.Name, device.Host, device.Port);
    }

    private async Task PollAsync()
    {
        foreach (var block in _blocks)
        {
            ushort[] registers = block.Table == RegisterTable.Holding
                ? await _master!.ReadHoldingRegistersAsync(device.UnitId, block.Start, block.Count)
                : await _master!.ReadInputRegistersAsync(device.UnitId, block.Start, block.Count);

            var timestamp = DateTimeOffset.UtcNow;
            foreach (var tag in block.Tags)
            {
                TagReading reading;
                try
                {
                    // Lo span resta un temporaneo: un metodo async non può dichiarare variabili Span
                    double value = RegisterDecoder.Decode(registers.AsSpan(tag.Address - block.Start, tag.RegisterCount), tag);
                    reading = new TagReading(device.Name, tag.Name, value, Quality.Good, timestamp, tag.Unit);
                }
                catch (InvalidDataException ex)
                {
                    logger.LogWarning("{Message}", ex.Message);
                    reading = TagReading.Bad(device.Name, tag.Name, tag.Unit);
                }
                await cache.Readings.Writer.WriteAsync(reading);
            }
        }
    }

    public async Task<double> WriteSetpointAsync(TagOptions tag, double value, CancellationToken cancellationToken)
    {
        ushort raw = RegisterDecoder.EncodeInt16(value, tag);

        await _gate.WaitAsync(cancellationToken);
        try
        {
            await EnsureConnectedAsync(cancellationToken);
            await _master!.WriteSingleRegisterAsync(device.UnitId, tag.Address, raw);
            // Rilettura immediata: solo il PLC sa quale valore è davvero in vigore
            ushort[] check = await _master.ReadHoldingRegistersAsync(device.UnitId, tag.Address, 1);
            return RegisterDecoder.Decode(check, tag);
        }
        catch (Exception ex) when (ex is IOException or SocketException or TimeoutException)
        {
            Disconnect();
            throw;
        }
        finally
        {
            _gate.Release();
        }
    }

    private async Task PublishAllBadAsync()
    {
        foreach (var tag in device.Tags)
        {
            await cache.Readings.Writer.WriteAsync(TagReading.Bad(device.Name, tag.Name, tag.Unit));
        }
    }

    private void Disconnect()
    {
        _master?.Dispose();
        _tcp?.Dispose();
        _master = null;
        _tcp = null;
    }

    private static List<Block> PlanBlocks(IEnumerable<TagOptions> tags)
    {
        var blocks = new List<Block>();
        foreach (var group in tags.GroupBy(t => t.Table))
        {
            var sorted = group.OrderBy(t => t.Address).ToList();
            int start = sorted[0].Address;
            int end = start + sorted[0].RegisterCount;
            var current = new List<TagOptions> { sorted[0] };

            foreach (var tag in sorted.Skip(1))
            {
                int tagEnd = tag.Address + tag.RegisterCount;
                if (tag.Address - end <= 4 && tagEnd - start <= MaxRegistersPerRead)
                {
                    end = Math.Max(end, tagEnd);
                    current.Add(tag);
                    continue;
                }
                blocks.Add(new Block(group.Key, (ushort)start, (ushort)(end - start), current));
                start = tag.Address;
                end = tagEnd;
                current = [tag];
            }
            blocks.Add(new Block(group.Key, (ushort)start, (ushort)(end - start), current));
        }
        return blocks;
    }

    public ValueTask DisposeAsync()
    {
        Disconnect();
        _gate.Dispose();
        return ValueTask.CompletedTask;
    }
}

Alcune scelte meritano una spiegazione. Retries = 0 disattiva i nuovi tentativi automatici di NModbus: per le letture sarebbero accettabili, ma per le scritture possono essere pericolosi, perché se la risposta si perde dopo che il PLC ha eseguito il comando, ripeterlo può produrre un effetto doppio. Preferiamo che la politica di retry sia esplicita e decisa dal servizio.

La gestione delle eccezioni distingue due famiglie. Una SlaveException significa che il PLC ha risposto, rifiutando la richiesta, per esempio con Illegal Data Address se un registro non esiste: la connessione è sana e chiuderla non servirebbe. Un errore di I/O o un timeout, invece, lasciano il flusso TCP in uno stato incerto, perché una risposta tardiva potrebbe arrivare in qualsiasi momento: la soluzione sicura è chiudere e riaprire.

L'ultimo blocco catch è una rete di sicurezza importante: da .NET 6 un'eccezione non gestita in un BackgroundService arresta l'intero host, quindi un errore imprevisto su un solo dispositivo fermerebbe l'acquisizione di tutti. Le due chiamate ContinueWith servono a trasformare la cancellazione in un'uscita pulita dal ciclo invece che in un'eccezione, così l'arresto del servizio non riempie il log di errori.

Il servizio ospitato

Un unico BackgroundService crea un poller per ogni dispositivo e li esegue in parallelo. Mantiene anche un indice dei tag scrivibili, usato dall'API.

// Modbus/ModbusAcquisitionService.cs
using Microsoft.Extensions.Options;
using ScadaService.Configuration;
using ScadaService.Tags;

namespace ScadaService.Modbus;

public sealed class ModbusAcquisitionService : BackgroundService
{
    private readonly List<DevicePoller> _pollers;
    private readonly Dictionary<string, (DevicePoller Poller, TagOptions Tag)> _writable;

    public ModbusAcquisitionService(IOptions<ScadaOptions> options, TagCache cache, ILoggerFactory loggerFactory)
    {
        _pollers = options.Value.Devices
            .Select(d => new DevicePoller(d, cache, loggerFactory.CreateLogger($"Modbus.{d.Name}")))
            .ToList();

        _writable = _pollers
            .SelectMany(p => p.Device.Tags.Where(t => t.Writable).Select(t => (Poller: p, Tag: t)))
            .ToDictionary(x => x.Tag.Name, x => x);
    }

    public bool TryGetWritable(string name, out DevicePoller poller, out TagOptions tag)
    {
        if (_writable.TryGetValue(name, out var entry))
        {
            (poller, tag) = entry;
            return true;
        }
        poller = null!;
        tag = null!;
        return false;
    }

    protected override Task ExecuteAsync(CancellationToken stoppingToken) =>
        Task.WhenAll(_pollers.Select(p => p.RunAsync(stoppingToken)));

    public override async Task StopAsync(CancellationToken cancellationToken)
    {
        await base.StopAsync(cancellationToken);
        foreach (var poller in _pollers)
        {
            await poller.DisposeAsync();
        }
    }
}

L'API e le regole di scrittura

Il file Program.cs registra i servizi e definisce tre endpoint: l'elenco dei tag con il loro stato, la lettura di un singolo tag e l'invio di un setpoint. Quest'ultimo applica controlli in sequenza: il tag deve essere dichiarato scrivibile nella configurazione, il valore deve rientrare nei limiti, la scrittura viene verificata per rilettura e l'operazione viene registrata in un log di audit.

Ogni lettura restituita dall'API riporta anche un indicatore di obsolescenza: se l'ultimo aggiornamento risale a più di tre intervalli di lettura, il valore viene marcato come non attuale. Un valore vecchio presentato come buono è più pericoloso di un valore mancante, perché nessuno lo mette in discussione.

// Program.cs
using ScadaService.Configuration;
using ScadaService.Modbus;
using ScadaService.Tags;

var builder = WebApplication.CreateBuilder(args);

builder.Services.Configure<ScadaOptions>(builder.Configuration.GetSection(ScadaOptions.SectionName));
builder.Services.AddSingleton<TagCache>();
builder.Services.AddHostedService<TagCacheUpdater>();
builder.Services.AddSingleton<ModbusAcquisitionService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<ModbusAcquisitionService>());

var app = builder.Build();
var audit = app.Services.GetRequiredService<ILoggerFactory>().CreateLogger("Audit");
var staleAfter = TimeSpan.FromSeconds(5);

app.MapGet("/api/tags", (TagCache cache) =>
    cache.Snapshot().Select(r => new
    {
        r.Device,
        r.Name,
        r.Value,
        Quality = r.Quality.ToString(),
        Stale = DateTimeOffset.UtcNow - r.Timestamp > staleAfter,
        r.Timestamp,
        r.Unit,
    }));

app.MapGet("/api/tags/{name}", (string name, TagCache cache) =>
    cache.Get(name) is { } reading ? Results.Ok(reading) : Results.NotFound());

app.MapPost("/api/tags/{name}/setpoint", async (
    string name,
    SetpointRequest request,
    ModbusAcquisitionService acquisition,
    TagCache cache,
    HttpContext http,
    CancellationToken cancellationToken) =>
{
    var user = http.User.Identity?.Name ?? "anonimo";

    if (!acquisition.TryGetWritable(name, out var poller, out var tag))
    {
        return Results.Problem($"Il tag {name} non è abilitato alla scrittura", statusCode: StatusCodes.Status403Forbidden);
    }
    if (tag.Min is not double min || tag.Max is not double max)
    {
        // Un tag scrivibile senza limiti dichiarati è un errore di configurazione: si rifiuta la scrittura
        return Results.Problem($"Limiti non configurati per {name}", statusCode: StatusCodes.Status500InternalServerError);
    }
    if (double.IsNaN(request.Value) || request.Value < min || request.Value > max)
    {
        return Results.Problem($"Valore fuori dai limiti [{min}, {max}]", statusCode: StatusCodes.Status422UnprocessableEntity);
    }

    var previous = cache.Get(name)?.Value;
    try
    {
        var confirmed = await poller.WriteSetpointAsync(tag, request.Value, cancellationToken);
        bool accepted = Math.Abs(confirmed - request.Value) <= Math.Abs(tag.Scale) / 2;
        audit.LogInformation("Utente {User} tag {Tag} da {Previous} richiesto {Requested} attivo {Confirmed} accettato {Accepted}",
            user, name, previous, request.Value, confirmed, accepted);

        return accepted
            ? Results.Ok(new { name, value = confirmed })
            : Results.Problem($"Il PLC ha mantenuto il valore {confirmed}", statusCode: StatusCodes.Status409Conflict);
    }
    catch (Exception ex)
    {
        // Esito incerto: il comando potrebbe essere stato eseguito prima dell'errore
        audit.LogWarning("Utente {User} tag {Tag} richiesto {Requested} esito incerto: {Error}", user, name, request.Value, ex.Message);
        return Results.Problem("Scrittura non confermata: verificare lo stato del tag prima di riprovare",
            statusCode: StatusCodes.Status502BadGateway);
    }
});

app.Run();

public sealed record SetpointRequest(double Value);

La registrazione di ModbusAcquisitionService merita attenzione. Il servizio deve essere sia un servizio ospitato, avviato dal runtime, sia un singleton iniettabile negli endpoint. Registrarlo due volte con AddHostedService<ModbusAcquisitionService>() e AddSingleton creerebbe due istanze distinte, con due serie di connessioni verso lo stesso PLC. La forma usata, con la factory che recupera il singleton, garantisce un'unica istanza.

Si noti anche il codice di stato per la scrittura non confermata. Il 502 comunica al chiamante che il problema è a valle e, insieme al messaggio, che non bisogna ritentare alla cieca. Il client dell'API dovrebbe rileggere il tag, verificare lo stato e solo allora decidere.

Un esempio di chiamata:

curl -s http://localhost:5000/api/tags | jq
curl -s -X POST http://localhost:5000/api/tags/barrel.zone1.setpoint/setpoint \
  -H "Content-Type: application/json" \
  -d '{"value": 215.5}'

Estendere il servizio a OPC UA

Molti PLC recenti e quasi tutti i server SCADA espongono i dati tramite OPC UA, che rispetto a Modbus offre nodi con nome e tipo, timestamp, codici di qualità, sottoscrizioni e sicurezza integrata. Per .NET il riferimento è lo stack ufficiale della OPC Foundation, distribuito come pacchetto OPCFoundation.NetStandard.Opc.Ua.Client. È completo e certificato, ma richiede più configurazione di una libreria Modbus: un'ApplicationConfiguration con identità dell'applicazione, archivi di certificati per l'istanza, per i certificati attendibili e per quelli rifiutati, la selezione dell'endpoint e la creazione di una sessione. Lo stack è in evoluzione rapida, con una serie 1.5 consolidata e una 2.0 appena pubblicata, per cui conviene partire dal client di riferimento per console incluso nel repository ufficiale e adattarlo alla versione scelta.

L'architettura costruita in questo articolo accoglie OPC UA senza modifiche strutturali. Basta un secondo servizio ospitato che apra la sessione, crei una sottoscrizione con un monitored item per ogni nodo e, nel gestore delle notifiche, scriva nello stesso canale TagCache.Readings. Il gestore deve restare rapido, perché viene invocato nel contesto di ricezione dello stack: scrivere su un canale è esattamente il tipo di operazione adatta. Il codice di stato OPC UA si traduce nella qualità del modello, e il timestamp della sorgente sostituisce quello di lettura.

Sicurezza

  • Segmentazione. Modbus TCP non ha autenticazione: il servizio deve essere l'unico sistema autorizzato a raggiungere i PLC, in una zona di rete dedicata secondo la IEC 62443, con firewall che blocca qualsiasi altro accesso alla porta 502.
  • Autenticazione dell'API. L'esempio non autentica gli utenti. In produzione l'endpoint di scrittura deve richiedere un'identità, per esempio con autenticazione Windows in un dominio aziendale o con token JWT, e una policy di autorizzazione dedicata, distinta da quella di lettura.
  • Scritture dichiarate. Solo i tag con Writable e limiti espliciti sono scrivibili; tutto il resto è in sola lettura per costruzione.
  • Audit persistente. Il logger di audit va indirizzato verso una destinazione durevole e protetta, separata dai log applicativi.
  • OPC UA con sicurezza attiva. Quando si aggiunge OPC UA, la modalità SignAndEncrypt con certificati gestiti è il requisito minimo, e il certificato del servizio va approvato esplicitamente sul server.

Conclusioni

Abbiamo costruito un servizio di acquisizione che sfrutta i punti di forza di .NET: servizi ospitati per il ciclo di vita, il pattern options per la mappa dei registri, Channel per disaccoppiare acquisizione e consumo, PeriodicTimer per un polling senza sovrapposizioni e un semaforo per serializzare l'accesso al dispositivo. Le regole più importanti, però, non dipendono dal linguaggio: distinguere gli errori di protocollo da quelli di trasporto, non ritentare mai automaticamente una scrittura, verificare ogni comando per rilettura e comunicare sempre la qualità e l'attualità del dato. Sono queste regole, più del codice, a rendere affidabile un software che dialoga con un impianto.