using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
///
/// Modbus TCP implementation of + +
/// + . First native-protocol greenfield
/// driver for the v2 stack — validates the driver-agnostic IAddressSpaceBuilder +
/// IReadable/IWritable abstractions generalize beyond Galaxy.
///
///
/// Scope limits: Historian + alarm capabilities are out of scope (the protocol doesn't
/// express them). Subscriptions overlay a polling loop via the shared
/// since Modbus has no native push model.
///
public sealed class ModbusDriver
: IDriver, ITagDiscovery, IReadable, IWritable, IBatchSubscribable, IHostConnectivityProbe, IPerCallHostResolver, ISkippedTagReporter, IDisposable, IAsyncDisposable
{
/// Authored tags this driver could not build into its poll table (Gitea #541).
private readonly SkippedTagLedger _skippedTags = new();
///
public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
// ---- instance fields (grouped at top for auditability) ----
/// Mutable so can adopt a re-parsed config on reinitialize
/// (#516). Only ever written on the init path, before any reader/transport uses it.
private ModbusDriverOptions _options;
private readonly Func _transportFactory;
private readonly string _driverInstanceId;
private readonly ILogger _logger;
// Polled subscriptions delegate to the shared PollGroupEngine. The driver only supplies
// the reader + on-change bridge; the engine owns the loop, interval floor, and lifecycle.
private readonly PollGroupEngine _poll;
// v3 authored-tag table, keyed by RawPath (the tag identity + driver wire reference). RawPath is
// case-sensitive ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper.
private readonly Dictionary _tagsByRawPath = new(StringComparer.Ordinal);
// Resolves a read/write/subscribe RawPath reference to its tag definition via a single hit on the
// authored _tagsByRawPath table (v3: the reference is always a RawPath; a miss is a miss).
private readonly EquipmentTagRefResolver _resolver;
// Last-published value per tag, keyed by FullReference. Used by ShouldPublish to apply
// the deadband filter. Stored as object so all numeric types share one map; the comparison
// does a typed cast inside.
private readonly ConcurrentDictionary _lastPublishedByRef = new(StringComparer.OrdinalIgnoreCase);
// Per-cycle buffer of the items this driver actually PUBLISHED (i.e. that passed ShouldPublish),
// keyed by subscription handle. The poll engine collects its own batch list unconditionally — it
// has no knowledge of the deadband filter — so the batch event is raised from this buffer instead,
// using the engine's onBatch purely as the end-of-cycle signal.
//
// No lock is needed on the per-handle List: the engine runs one loop task per subscription, so for
// ONE handle all of a cycle's onChange calls and that cycle's single onBatch run sequentially on
// that subscription's own task. Different subscriptions run concurrently — hence the per-handle
// ConcurrentDictionary.
private readonly ConcurrentDictionary> _cycleBatchItems = new();
// Last-written value per tag for the WriteOnChangeOnly suppression. Invalidated by reads
// that return a different value (so an HMI-side change doesn't get masked).
private readonly Dictionary _lastWrittenByRef = new(StringComparer.OrdinalIgnoreCase);
private readonly object _lastWrittenLock = new();
// BitInRegister writes need a read-modify-write against the full holding register. A
// per-register lock keeps concurrent bit-write callers from stomping on each other.
private readonly ConcurrentDictionary _rmwLocks = new();
private readonly Dictionary<(byte Unit, ModbusRegion Region, ushort Start, ushort End), ProhibitionState> _autoProhibited = new();
private readonly object _autoProhibitedLock = new();
// Single-host probe state — Modbus driver talks to exactly one endpoint so the "hosts"
// collection has at most one entry. HostName is the Host:Port string so the Admin UI can
// display the PLC endpoint uniformly with Galaxy platforms/engines.
private readonly object _probeLock = new();
private HostState _hostState = HostState.Unknown;
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
private IModbusTransport? _transport;
private CancellationTokenSource? _probeCts;
private CancellationTokenSource? _reprobeCts;
private DriverHealth _health = new(DriverState.Unknown, null, null);
/// Occurs when a subscribed tag value changes.
public event EventHandler? OnDataChange;
///
/// Occurs once per poll cycle that produced at least one change, carrying that cycle's
/// changed tags. Both data-change events fire by design — this one once per cycle,
/// once per changed tag — so a consumer subscribes to EXACTLY
/// ONE of the two; subscribing both double-delivers.
///
public event EventHandler? OnDataChangeBatch;
/// Occurs when host connectivity status changes.
public event EventHandler? OnHostStatusChanged;
// ---- nested types ----
///
/// Per-prohibition state. SplitPending drives the re-probe loop's
/// bisection: when true and the range spans > 1 register, the next re-probe
/// tries the two halves separately to narrow the actual offending register(s).
/// Single-register prohibitions can't be split further; they stay re-probed as-is.
///
private sealed class ProhibitionState
{
public DateTime LastProbedUtc;
public bool SplitPending;
}
// ---- ctor + identity ----
/// Initializes a new Modbus TCP driver with the specified options and transport factory.
/// Driver configuration options.
/// Unique identifier for this driver instance.
/// Factory to create the Modbus transport; defaults to
/// , which honours .
/// Logger instance; defaults to null logger if not provided.
public ModbusDriver(ModbusDriverOptions options, string driverInstanceId,
Func? transportFactory = null,
ILogger? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
_logger = logger ?? NullLogger.Instance;
_resolver = new EquipmentTagRefResolver(
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
_transportFactory = transportFactory
?? (o => ModbusTransportFactory.Create(o));
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
{
if (!ShouldPublish(tagRef, snapshot)) return;
// Buffer what we publish so the cycle-end batch carries exactly the same items.
_cycleBatchItems.GetOrAdd(handle, _ => new List())
.Add(new DataChangeItem(tagRef, snapshot));
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot));
},
onError: HandlePollError,
backoffCap: PollBackoffCap,
// The engine's own items list is deliberately IGNORED: it is collected pre-deadband
// (PollGroupEngine has no knowledge of ShouldPublish), so forwarding it would carry
// changes the per-tag event suppressed — and a consumer that prefers the batch event
// would see deadband silently stop working. The buffer holds exactly what the per-tag
// event published, keeping the two events content-identical per the IBatchSubscribable
// contract. onBatch is used only as the end-of-cycle signal. A cycle whose every change
// was suppressed leaves the buffer empty and raises nothing.
onBatch: (handle, _) =>
{
if (_cycleBatchItems.TryRemove(handle, out var published) && published.Count > 0)
OnDataChangeBatch?.Invoke(this, new DataChangeBatchEventArgs(handle, published));
});
}
/// Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
///
/// 05/STAB-9 — routes a poll-loop reader failure to the driver health surface: logs it and
/// degrades to preserving LastSuccessfulRead.
/// Never downgrades a state (a stronger, config-level signal).
///
/// The exception caught by the poll engine.
internal void HandlePollError(Exception ex)
{
_logger.LogWarning(ex, "Modbus poll reader failed. Driver={DriverInstanceId}", _driverInstanceId);
var current = ReadHealth();
if (current.State != DriverState.Faulted)
WriteHealth(new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message));
}
///
/// Resolve a reference to the per-slave host key (host:port/unitN) that keys its
/// per-host resilience (bulkhead / circuit breaker). CONV-4: routes through
/// so an equipment-tag reference (raw TagConfig
/// JSON) keys its OWN authored unit — via the tag's optional unitId or the driver
/// default — rather than the single-slave fallback. The resolver caches parses, so this adds
/// no per-call cost after first use.
///
/// The tag reference (authored name or equipment-tag JSON).
/// The per-slave host key.
public string ResolveHost(string fullReference)
{
if (_resolver.TryResolve(fullReference, out var tag))
return BuildSlaveHostName(ResolveUnitId(tag));
// Unknown reference — fall back to driver-instance host (single-slave behaviour).
return HostName;
}
/// Format a per-slave host string. Multi-slave deployments distinguish breakers by this string.
private string BuildSlaveHostName(byte unitId) => $"{_options.Host}:{_options.Port}/unit{unitId}";
private bool ShouldPublish(string tagRef, DataValueSnapshot snapshot)
{
if (!_resolver.TryResolve(tagRef, out var tag) || tag.Deadband is null) return true;
if (snapshot.Value is null) return true;
// Deadband only applies to numeric scalar types — array / Bool / String publishes
// unconditionally. Easier to special-case skip than to enumerate the supported types.
if (tag.ArrayCount.HasValue || tag.DataType is ModbusDataType.Bool or ModbusDataType.BitInRegister or ModbusDataType.String)
return true;
if (!_lastPublishedByRef.TryGetValue(tagRef, out var prev))
{
// First sample passes through unconditionally — the threshold can't be evaluated
// without a baseline. The publish lands and seeds the comparison.
_lastPublishedByRef[tagRef] = snapshot.Value;
return true;
}
var newD = Convert.ToDouble(snapshot.Value);
var oldD = Convert.ToDouble(prev);
if (Math.Abs(newD - oldD) < tag.Deadband.Value) return false;
_lastPublishedByRef[tagRef] = snapshot.Value;
return true;
}
///
public string DriverInstanceId => _driverInstanceId;
///
public string DriverType => "Modbus";
/// True when the supplied DriverConfig JSON carries a real body. The bootstrapper always
/// passes a populated document; some unit tests pass "{}" or an empty string to exercise
/// lifecycle shape without a config — those keep the constructor-supplied options.
private static bool HasConfigBody(string? driverConfigJson)
{
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
var trimmed = driverConfigJson.Trim();
return trimmed is not "{}" and not "[]";
}
///
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
try
{
// #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver
// contract is honoured. Without this the driver keeps serving the options it was CONSTRUCTED
// with, so an operator's edit is discarded while the deployment still seals green. An empty /
// placeholder document (the "{}" some unit tests pass) keeps the constructor-supplied options.
if (HasConfigBody(driverConfigJson))
_options = ModbusDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
_transport = _transportFactory(_options);
await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false);
// Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig
// blob is mapped by the pure factory; the entry's WriteIdempotent flag is threaded onto the
// def (it lives on the RawTagEntry, not inside the blob). A mapper miss is skipped (logged),
// never thrown — a bad tag must not fail the whole driver init.
_skippedTags.BeginRebuild(_options.RawTags.Count);
foreach (var entry in _options.RawTags)
{
if (ModbusTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
_tagsByRawPath[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent };
else
{
_skippedTags.Skip(entry.RawPath, "TagConfig did not map to a definition");
_logger.LogWarning(
"Modbus tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
_driverInstanceId, entry.RawPath);
}
}
_skippedTags.Commit();
WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
// PR 23: kick off the probe loop once the transport is up. Initial state stays
// Unknown until the first probe tick succeeds — avoids broadcasting a premature
// Running transition before any register round-trip has happened.
if (_options.Probe.Enabled)
{
_probeCts = new CancellationTokenSource();
_ = Task.Run(() => ProbeLoopAsync(_probeCts.Token), _probeCts.Token);
}
if (_options.AutoProhibitReprobeInterval is not null)
{
_reprobeCts = new CancellationTokenSource();
_ = Task.Run(() => ReprobeLoopAsync(_reprobeCts.Token), _reprobeCts.Token);
}
}
catch (Exception ex)
{
WriteHealth(new DriverHealth(DriverState.Faulted, null, ex.Message));
throw;
}
}
///
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await ShutdownAsync(cancellationToken);
await InitializeAsync(driverConfigJson, cancellationToken);
}
///
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
var lastRead = ReadHealth().LastSuccessfulRead;
await TeardownAsync().ConfigureAwait(false);
WriteHealth(new DriverHealth(DriverState.Unknown, lastRead, null));
}
///
public DriverHealth GetHealth() => ReadHealth();
///
/// Barrier-protected read of the multi-thread _health field.
/// Volatile.Read guarantees GetHealth() and the in-driver self-reads (the
/// Degraded paths that retain LastSuccessfulRead) observe the most recently
/// published snapshot rather than a per-core cached stale copy.
///
private DriverHealth ReadHealth() => Volatile.Read(ref _health);
///
/// Barrier-protected publish of a new _health snapshot.
///
private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value);
// ---- ITagDiscovery ----
///
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
///
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
var folder = builder.Folder("Modbus", "Modbus");
// Surface the authored raw tags (mapped into the RawPath → def table at Initialize). The def's
// Name is the RawPath, used for both the browse name and the driver FullName.
foreach (var t in _tagsByRawPath.Values)
{
folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
FullName: t.Name,
DriverDataType: MapDataType(t.DataType),
IsArray: t.ArrayCount.HasValue,
ArrayDim: t.ArrayCount.HasValue ? (uint)t.ArrayCount.Value : null,
SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: t.WriteIdempotent));
}
return Task.CompletedTask;
}
// ---- IReadable ----
///
public async Task> ReadAsync(
IReadOnlyList fullReferences, CancellationToken cancellationToken)
{
var transport = RequireTransport();
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
var coalesced = _options.MaxReadGap > 0
? await ReadCoalescedAsync(transport, fullReferences, results, now, cancellationToken).ConfigureAwait(false)
: new HashSet();
for (var i = 0; i < fullReferences.Count; i++)
{
if (coalesced.Contains(i)) continue;
if (!_resolver.TryResolve(fullReferences[i], out var tag))
{
results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
continue;
}
try
{
var value = await ReadOneAsync(transport, tag, cancellationToken).ConfigureAwait(false);
results[i] = new DataValueSnapshot(value, 0u, now, now);
WriteHealth(new DriverHealth(DriverState.Healthy, now, null));
// Invalidate the WriteOnChangeOnly cache when the read returns a different value
// — typically an HMI-side or PLC-internal change. Without this, a setpoint
// tweaked at the panel could be silently re-suppressed when our client tried
// to restore it.
if (_options.WriteOnChangeOnly)
{
lock (_lastWrittenLock)
{
if (_lastWrittenByRef.TryGetValue(fullReferences[i], out var prev) && !Equals(prev, value))
_lastWrittenByRef.Remove(fullReferences[i]);
}
}
}
catch (ModbusException mex)
{
results[i] = new DataValueSnapshot(null, MapModbusExceptionToStatus(mex.ExceptionCode), null, now);
WriteHealth(new DriverHealth(DriverState.Degraded, ReadHealth().LastSuccessfulRead, mex.Message));
}
catch (Exception ex)
{
// Non-Modbus-layer failure: socket dropped, timeout, malformed response. Surface
// as communication error so callers can distinguish it from tag-level faults.
results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
WriteHealth(new DriverHealth(DriverState.Degraded, ReadHealth().LastSuccessfulRead, ex.Message));
}
}
return results;
}
private async Task