a6ae4e22d1
Driver.Cli.Common-007 + Driver.Cli.Common-008 resolution.
Driver.Cli.Common-007 (High, Correctness):
0x80550000 is the canonical OPC UA spec value for BadSecurityPolicyRejected,
not BadDeviceFailure. The correct spec value for BadDeviceFailure is
0x808B0000 (verified against OPC Foundation Opc.Ua.StatusCodes;
corroborated locally by Driver.Galaxy.Runtime.StatusCodeMap and both
Wonderware historian quality mappers which all hand-pin the correct
value).
The bug was duplicated across six driver modules:
- FocasStatusMapper.BadDeviceFailure
- AbCipStatusMapper.BadDeviceFailure
- AbLegacyStatusMapper.BadDeviceFailure
- TwinCATStatusMapper.BadDeviceFailure
- ModbusDriver.StatusBadDeviceFailure
- S7Driver.StatusBadDeviceFailure
Plus the SnapshotFormatter shortlist that named 0x80550000 as
BadDeviceFailure, and three downstream Modbus tests that asserted
against the wrong value (so CI was blind).
This commit fixes all six native-mapper constants, the formatter
shortlist, and the three Modbus tests in one pass. Added a regression
guard to FormatStatus_does_not_apply_pre_fix_wrong_names that pins
0x80550000 never renders as BadDeviceFailure (mirroring the existing
-001 wrong-name guards).
Behavior change: OPC UA clients consuming the native drivers now see
the canonical BadDeviceFailure (0x808B0000) on device-fault paths
instead of the misnamed BadSecurityPolicyRejected (0x80550000). Wire-
level status semantics now match operator-facing CLI labels.
Driver.Cli.Common-008 (Low, Testing):
Deleted the redundant FormatStatus_names_native_driver_emitted_codes
Theory — its five InlineData rows were already covered by the
well-known Theory in the same commit (5a9c459), and used a weaker
ShouldContain vs the well-known Theory's ShouldBe (exact match).
Verification:
- Driver.Cli.Common.Tests: 43/43 pass (was 48 after the -008 deletion).
- Driver.Modbus.Tests: 263/263 pass.
- Driver.AbCip.Tests: 262/262.
- Driver.AbLegacy.Tests: 157/157.
- Driver.FOCAS.Tests: 178/178.
- Driver.S7.Tests: 112/112.
- Driver.TwinCAT.Tests: 131/131.
Total: 1146 tests across the affected modules, all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1577 lines
76 KiB
C#
1577 lines
76 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Modbus TCP implementation of <see cref="IDriver"/> + <see cref="ITagDiscovery"/> +
|
|
/// <see cref="IReadable"/> + <see cref="IWritable"/>. First native-protocol greenfield
|
|
/// driver for the v2 stack — validates the driver-agnostic <c>IAddressSpaceBuilder</c> +
|
|
/// <c>IReadable</c>/<c>IWritable</c> abstractions generalize beyond Galaxy.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Scope limits: Historian + alarm capabilities are out of scope (the protocol doesn't
|
|
/// express them). Subscriptions overlay a polling loop via the shared
|
|
/// <see cref="PollGroupEngine"/> since Modbus has no native push model.
|
|
/// </remarks>
|
|
public sealed class ModbusDriver
|
|
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
|
|
{
|
|
// ---- instance fields (Driver.Modbus-011: grouped at top for auditability) ----
|
|
|
|
private readonly ModbusDriverOptions _options;
|
|
private readonly Func<ModbusDriverOptions, IModbusTransport> _transportFactory;
|
|
private readonly string _driverInstanceId;
|
|
private readonly ILogger<ModbusDriver> _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;
|
|
|
|
private readonly Dictionary<string, ModbusTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
// 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.
|
|
// Driver.Modbus-001: ShouldPublish runs on the PollGroupEngine onChange callback, which
|
|
// executes on one background Task per subscription — so a multi-subscription driver mutates
|
|
// this map concurrently from several threads. A plain Dictionary corrupts under concurrent
|
|
// writes; ConcurrentDictionary makes every TryGetValue / indexer write thread-safe.
|
|
private readonly ConcurrentDictionary<string, object> _lastPublishedByRef = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
// 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<string, object?> _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<ushort, SemaphoreSlim> _rmwLocks = new();
|
|
|
|
// #148 auto-prohibited coalesce ranges + #150 bisection state (see ProhibitionState below).
|
|
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;
|
|
|
|
// Driver.Modbus-003: every read / write / probe path writes to _health from a different
|
|
// thread, and GetHealth() reads it without coordination. Reference-assignment on .NET is
|
|
// atomic for sealed-record refs (so no tearing), but without a happens-before barrier a
|
|
// stale snapshot can persist on another core indefinitely. Volatile.Write / Volatile.Read
|
|
// give GetHealth() a defined ordering guarantee: any subsequent read sees at least the
|
|
// most recent write any thread has published. The field stays a plain reference (you can't
|
|
// mark a record-typed field 'volatile' through the C# keyword on every framework version,
|
|
// and the Volatile API is the documented portable form).
|
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
|
|
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
|
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
|
|
|
// ---- nested types ----
|
|
|
|
/// <summary>
|
|
/// #150 — per-prohibition state. <c>SplitPending</c> 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.
|
|
/// </summary>
|
|
private sealed class ProhibitionState
|
|
{
|
|
public DateTime LastProbedUtc;
|
|
public bool SplitPending;
|
|
}
|
|
|
|
// ---- ctor + identity ----
|
|
|
|
public ModbusDriver(ModbusDriverOptions options, string driverInstanceId,
|
|
Func<ModbusDriverOptions, IModbusTransport>? transportFactory = null,
|
|
ILogger<ModbusDriver>? logger = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
_options = options;
|
|
_driverInstanceId = driverInstanceId;
|
|
_logger = logger ?? NullLogger<ModbusDriver>.Instance;
|
|
_transportFactory = transportFactory
|
|
?? (o => new ModbusTcpTransport(
|
|
o.Host, o.Port, o.Timeout, o.AutoReconnect,
|
|
keepAlive: o.KeepAlive,
|
|
idleDisconnect: o.IdleDisconnectTimeout,
|
|
reconnect: o.Reconnect));
|
|
_poll = new PollGroupEngine(
|
|
reader: ReadAsync,
|
|
onChange: (handle, tagRef, snapshot) =>
|
|
{
|
|
// #141 deadband filter: when configured on a tag, suppress publishes whose
|
|
// numeric distance from the last-published value is below the threshold.
|
|
if (!ShouldPublish(tagRef, snapshot)) return;
|
|
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot));
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// #142 multi-unit-ID gateway support: per-tag UnitId override drives per-slave host
|
|
/// name surfacing through this method. The resilience pipeline keys breakers on the
|
|
/// returned host string, so a dead RTU slave behind an Ethernet gateway opens its own
|
|
/// breaker without tripping siblings on the same TCP socket.
|
|
/// </summary>
|
|
public string ResolveHost(string fullReference)
|
|
{
|
|
if (_tagsByName.TryGetValue(fullReference, out var tag))
|
|
return BuildSlaveHostName(ResolveUnitId(tag));
|
|
// Unknown reference — fall back to driver-instance host (single-slave behaviour).
|
|
return HostName;
|
|
}
|
|
|
|
/// <summary>Format a per-slave host string. Multi-slave deployments distinguish breakers by this string.</summary>
|
|
private string BuildSlaveHostName(byte unitId) => $"{_options.Host}:{_options.Port}/unit{unitId}";
|
|
|
|
private bool ShouldPublish(string tagRef, DataValueSnapshot snapshot)
|
|
{
|
|
if (!_tagsByName.TryGetValue(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";
|
|
|
|
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
|
{
|
|
WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
|
|
try
|
|
{
|
|
_transport = _transportFactory(_options);
|
|
await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
|
foreach (var t in _options.Tags) _tagsByName[t.Name] = t;
|
|
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);
|
|
}
|
|
|
|
// #151 — start the auto-prohibition re-probe loop when the operator opted in.
|
|
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();
|
|
|
|
/// <summary>
|
|
/// Driver.Modbus-003: barrier-protected read of the multi-thread <c>_health</c> field.
|
|
/// <c>Volatile.Read</c> guarantees <c>GetHealth()</c> and the in-driver self-reads (the
|
|
/// Degraded paths that retain <c>LastSuccessfulRead</c>) observe the most recently
|
|
/// published snapshot rather than a per-core cached stale copy.
|
|
/// </summary>
|
|
private DriverHealth ReadHealth() => Volatile.Read(ref _health);
|
|
|
|
/// <summary>
|
|
/// Driver.Modbus-003: barrier-protected publish of a new <c>_health</c> snapshot.
|
|
/// </summary>
|
|
private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value);
|
|
public long GetMemoryFootprint() => 0;
|
|
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
|
|
// ---- ITagDiscovery ----
|
|
|
|
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
var folder = builder.Folder("Modbus", "Modbus");
|
|
foreach (var t in _options.Tags)
|
|
{
|
|
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<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
|
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
|
{
|
|
var transport = RequireTransport();
|
|
var now = DateTime.UtcNow;
|
|
var results = new DataValueSnapshot[fullReferences.Count];
|
|
|
|
// #143 block-read coalescing: when MaxReadGap is non-zero, route eligible tags through
|
|
// the coalescing planner first. Tags it can't coalesce (arrays, coils, prohibited,
|
|
// unknown) fall through to the per-tag loop below with results[i] still default.
|
|
var coalesced = _options.MaxReadGap > 0
|
|
? await ReadCoalescedAsync(transport, fullReferences, results, now, cancellationToken).ConfigureAwait(false)
|
|
: new HashSet<int>();
|
|
|
|
for (var i = 0; i < fullReferences.Count; i++)
|
|
{
|
|
if (coalesced.Contains(i)) continue;
|
|
if (!_tagsByName.TryGetValue(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<object> ReadOneAsync(IModbusTransport transport, ModbusTagDefinition tag, CancellationToken ct)
|
|
{
|
|
var arrayCount = tag.ArrayCount ?? 1;
|
|
|
|
switch (tag.Region)
|
|
{
|
|
case ModbusRegion.Coils:
|
|
case ModbusRegion.DiscreteInputs:
|
|
{
|
|
// FC01 (Coils) / FC02 (DiscreteInputs). Auto-chunk when array count exceeds the
|
|
// coil cap — Modbus spec says ≤ 2000 bits per request; some devices cap lower
|
|
// (we trust the caller-provided MaxCoilsPerRead).
|
|
var fc = tag.Region == ModbusRegion.Coils ? (byte)0x01 : (byte)0x02;
|
|
var cap = _options.MaxCoilsPerRead == 0 ? (ushort)2000 : _options.MaxCoilsPerRead;
|
|
var unitId = ResolveUnitId(tag);
|
|
var bitmap = arrayCount <= cap
|
|
? await ReadBitBlockAsync(transport, unitId, fc, tag.Address, (ushort)arrayCount, ct).ConfigureAwait(false)
|
|
: await ReadBitBlockChunkedAsync(transport, unitId, fc, tag.Address, arrayCount, cap, ct).ConfigureAwait(false);
|
|
return DecodeBitArray(bitmap, arrayCount, tag.ArrayCount.HasValue);
|
|
}
|
|
case ModbusRegion.HoldingRegisters:
|
|
case ModbusRegion.InputRegisters:
|
|
{
|
|
var elementRegs = RegisterCount(tag);
|
|
var totalRegs = (ushort)(elementRegs * arrayCount);
|
|
var fc = tag.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
|
|
// Auto-chunk when the tag's register span exceeds the caller-configured cap.
|
|
// Affects long strings + arrays (FC03/04 > 125 regs is spec-forbidden; DL205 caps
|
|
// at 128, Mitsubishi Q caps at 64). Scalar non-string tags max out at 4 regs so
|
|
// the cap never triggers for them.
|
|
var cap = _options.MaxRegistersPerRead == 0 ? (ushort)125 : _options.MaxRegistersPerRead;
|
|
var unitId = ResolveUnitId(tag);
|
|
var data = totalRegs <= cap
|
|
? await ReadRegisterBlockAsync(transport, unitId, fc, tag.Address, totalRegs, ct).ConfigureAwait(false)
|
|
: await ReadRegisterBlockChunkedAsync(transport, unitId, fc, tag.Address, totalRegs, cap, ct).ConfigureAwait(false);
|
|
|
|
if (!tag.ArrayCount.HasValue)
|
|
return DecodeRegister(data, tag);
|
|
|
|
return DecodeRegisterArray(data, tag, elementRegs, arrayCount);
|
|
}
|
|
default:
|
|
throw new InvalidOperationException($"Unknown region {tag.Region}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Decode an FC01/FC02 coil-bitmap response into either a single bool (scalar tag) or a
|
|
/// bool[] of <paramref name="count"/> elements (array tag). Modbus packs coils LSB-first
|
|
/// within each byte, ascending address across bytes.
|
|
/// </summary>
|
|
private static object DecodeBitArray(ReadOnlySpan<byte> bitmap, int count, bool isArray)
|
|
{
|
|
// Driver.Modbus-005: guard against empty bitmap (already validated upstream but defensive
|
|
// here so the IndexOutOfRangeException path is explicitly closed at decode time too).
|
|
if (bitmap.IsEmpty)
|
|
throw new InvalidDataException("Modbus bit response produced an empty bitmap — cannot decode coil value");
|
|
if (!isArray) return (bitmap[0] & 0x01) == 1;
|
|
var result = new bool[count];
|
|
for (var i = 0; i < count; i++)
|
|
result[i] = ((bitmap[i / 8] >> (i % 8)) & 0x01) == 1;
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Decode an array of register-backed values from a contiguous block. Each element
|
|
/// occupies <paramref name="elementRegs"/> registers and is decoded with the same
|
|
/// codec the scalar path uses, sliced from its position in the block.
|
|
/// </summary>
|
|
private static object DecodeRegisterArray(byte[] data, ModbusTagDefinition tag, int elementRegs, int count)
|
|
{
|
|
var elementBytes = elementRegs * 2;
|
|
// Element type drives the array CLR type. Boxed into Array so the Read pipeline can
|
|
// surface it directly without a per-call type-switch on the caller side.
|
|
switch (tag.DataType)
|
|
{
|
|
case ModbusDataType.Int16:
|
|
{
|
|
var arr = new short[count];
|
|
for (var i = 0; i < count; i++)
|
|
arr[i] = (short)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
|
|
return arr;
|
|
}
|
|
case ModbusDataType.UInt16:
|
|
{
|
|
var arr = new ushort[count];
|
|
for (var i = 0; i < count; i++)
|
|
arr[i] = (ushort)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
|
|
return arr;
|
|
}
|
|
case ModbusDataType.Int32:
|
|
case ModbusDataType.Bcd16:
|
|
case ModbusDataType.Bcd32:
|
|
{
|
|
var arr = new int[count];
|
|
for (var i = 0; i < count; i++)
|
|
arr[i] = (int)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
|
|
return arr;
|
|
}
|
|
case ModbusDataType.UInt32:
|
|
{
|
|
var arr = new uint[count];
|
|
for (var i = 0; i < count; i++)
|
|
arr[i] = (uint)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
|
|
return arr;
|
|
}
|
|
case ModbusDataType.Int64:
|
|
{
|
|
var arr = new long[count];
|
|
for (var i = 0; i < count; i++)
|
|
arr[i] = (long)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
|
|
return arr;
|
|
}
|
|
case ModbusDataType.UInt64:
|
|
{
|
|
var arr = new ulong[count];
|
|
for (var i = 0; i < count; i++)
|
|
arr[i] = (ulong)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
|
|
return arr;
|
|
}
|
|
case ModbusDataType.Float32:
|
|
{
|
|
var arr = new float[count];
|
|
for (var i = 0; i < count; i++)
|
|
arr[i] = (float)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
|
|
return arr;
|
|
}
|
|
case ModbusDataType.Float64:
|
|
{
|
|
var arr = new double[count];
|
|
for (var i = 0; i < count; i++)
|
|
arr[i] = (double)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
|
|
return arr;
|
|
}
|
|
default:
|
|
throw new InvalidOperationException(
|
|
$"Array decode not supported for {tag.DataType} (use scalar tags or split by element)");
|
|
}
|
|
}
|
|
|
|
/// <summary>Resolve the UnitId for a tag — per-tag override (#142) or driver-level fallback.</summary>
|
|
private byte ResolveUnitId(ModbusTagDefinition tag) => tag.UnitId ?? _options.UnitId;
|
|
|
|
private bool RangeIsAutoProhibited(byte unit, ModbusRegion region, ushort start, ushort end)
|
|
{
|
|
lock (_autoProhibitedLock)
|
|
{
|
|
foreach (var p in _autoProhibited.Keys)
|
|
{
|
|
// A candidate (start..end) range is prohibited if it overlaps any recorded
|
|
// failure. Overlap rule: max-start ≤ min-end. We don't try to be smart about
|
|
// partial overlap — once a range fails, any superset of it is also untrusted.
|
|
if (p.Unit != unit || p.Region != region) continue;
|
|
if (Math.Max(start, p.Start) <= Math.Min(end, p.End)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private void RecordAutoProhibition(byte unit, ModbusRegion region, ushort start, ushort end)
|
|
{
|
|
bool isNew;
|
|
lock (_autoProhibitedLock)
|
|
{
|
|
// Multi-register prohibitions enter the bisection workflow on the next re-probe;
|
|
// single-register prohibitions are already minimal and skip bisection.
|
|
isNew = !_autoProhibited.ContainsKey((unit, region, start, end));
|
|
_autoProhibited[(unit, region, start, end)] = new ProhibitionState
|
|
{
|
|
LastProbedUtc = DateTime.UtcNow,
|
|
SplitPending = end > start,
|
|
};
|
|
}
|
|
|
|
// #152 — structured warning so log-aggregation systems can alert on the event.
|
|
// First-time prohibitions get logged; re-fires of the same range stay quiet to avoid
|
|
// flooding when a per-tick exception keeps the same range bad. The state visible via
|
|
// GetAutoProhibitedRanges shows operators the long-tail picture.
|
|
if (isNew)
|
|
_logger.LogWarning(
|
|
"Modbus coalesced read failed; auto-prohibited range recorded. Driver={DriverInstanceId} Unit={Unit} Region={Region} Start={Start} End={End} Span={Span}",
|
|
_driverInstanceId, unit, region, start, end, end - start + 1);
|
|
}
|
|
|
|
/// <summary>
|
|
/// #153 — info log when a re-probe clears a prohibition. Operators see recovery
|
|
/// events without having to poll <see cref="GetAutoProhibitedRanges"/>.
|
|
/// </summary>
|
|
private void LogProhibitionCleared(byte unit, ModbusRegion region, ushort start, ushort end) =>
|
|
_logger.LogInformation(
|
|
"Modbus auto-prohibition cleared by re-probe. Driver={DriverInstanceId} Unit={Unit} Region={Region} Start={Start} End={End}",
|
|
_driverInstanceId, unit, region, start, end);
|
|
|
|
/// <summary>
|
|
/// #152 — operator-visible snapshot of every range the planner has learned to read
|
|
/// individually. Exposed through the driver-diagnostics surface; consumers (Admin UI,
|
|
/// log-aggregation, dashboards) call this to show what's been auto-isolated. Populated
|
|
/// on coalesced-read failure (#148), narrowed by bisection (#150), cleared by the
|
|
/// re-probe loop (#151) when ranges become healthy again.
|
|
/// </summary>
|
|
public IReadOnlyList<ModbusAutoProhibition> GetAutoProhibitedRanges()
|
|
{
|
|
lock (_autoProhibitedLock)
|
|
return _autoProhibited
|
|
.Select(kv => new ModbusAutoProhibition(
|
|
kv.Key.Unit, kv.Key.Region, kv.Key.Start, kv.Key.End,
|
|
kv.Value.LastProbedUtc, kv.Value.SplitPending))
|
|
.ToArray();
|
|
}
|
|
|
|
/// <summary>Test/diagnostic accessor — returns the current auto-prohibited range count.</summary>
|
|
internal int AutoProhibitedRangeCount
|
|
{
|
|
get { lock (_autoProhibitedLock) return _autoProhibited.Count; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// #151 — periodic re-probe loop, augmented in #150 with bisection-style narrowing.
|
|
/// Each tick processes every prohibition: split-pending multi-register ranges get
|
|
/// bisected (try left + right halves; replace with whichever halves still fail),
|
|
/// single-register or non-split-pending ranges get a straight re-probe. Lives for
|
|
/// the driver lifetime; cancelled by <c>ShutdownAsync</c>.
|
|
/// </summary>
|
|
private async Task ReprobeLoopAsync(CancellationToken ct)
|
|
{
|
|
var interval = _options.AutoProhibitReprobeInterval!.Value;
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
try { await Task.Delay(interval, ct).ConfigureAwait(false); }
|
|
catch (OperationCanceledException) { return; }
|
|
try { await RunReprobeOnceForTestAsync(ct).ConfigureAwait(false); }
|
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
|
|
catch (ObjectDisposedException) when (ct.IsCancellationRequested)
|
|
{
|
|
// Driver.Modbus-006: ShutdownAsync disposes the transport while we may be
|
|
// mid-pass. An ObjectDisposedException from the disposed transport is the
|
|
// expected shutdown race — swallow it here so the fire-and-forget task
|
|
// exits cleanly rather than faulting with the wrong failure mode.
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// One re-probe pass. Public-but-internal so tests can drive it synchronously rather
|
|
/// than wait on the background timer. Iterates a snapshot of the prohibition set; for
|
|
/// each entry decides between bisection (multi-register + SplitPending) or straight
|
|
/// retry (single-register or already-narrowed).
|
|
/// </summary>
|
|
internal async Task RunReprobeOnceForTestAsync(CancellationToken ct)
|
|
{
|
|
var transport = _transport ?? throw new InvalidOperationException("Transport not connected");
|
|
|
|
((byte Unit, ModbusRegion Region, ushort Start, ushort End) Key, bool SplitPending)[] candidates;
|
|
lock (_autoProhibitedLock)
|
|
candidates = _autoProhibited
|
|
.Select(kv => (Key: kv.Key, SplitPending: kv.Value.SplitPending))
|
|
.ToArray();
|
|
|
|
foreach (var (key, splitPending) in candidates)
|
|
{
|
|
if (ct.IsCancellationRequested) return;
|
|
if (splitPending && key.End > key.Start)
|
|
await BisectAndReprobeAsync(transport, key, ct).ConfigureAwait(false);
|
|
else
|
|
await StraightReprobeAsync(transport, key, ct).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private async Task StraightReprobeAsync(IModbusTransport transport,
|
|
(byte Unit, ModbusRegion Region, ushort Start, ushort End) key, CancellationToken ct)
|
|
{
|
|
var fc = key.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
|
|
var qty = (ushort)(key.End - key.Start + 1);
|
|
try
|
|
{
|
|
_ = await ReadRegisterBlockAsync(transport, key.Unit, fc, key.Start, qty, ct).ConfigureAwait(false);
|
|
lock (_autoProhibitedLock) _autoProhibited.Remove(key);
|
|
LogProhibitionCleared(key.Unit, key.Region, key.Start, key.End);
|
|
}
|
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
|
catch
|
|
{
|
|
lock (_autoProhibitedLock)
|
|
if (_autoProhibited.TryGetValue(key, out var st)) st.LastProbedUtc = DateTime.UtcNow;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// #150 — bisect a multi-register prohibition. Removes the parent entry and re-adds
|
|
/// whichever halves still fail. Over multiple re-probe ticks the prohibition narrows
|
|
/// log2(span) times until it pinpoints the actual protected register(s).
|
|
/// </summary>
|
|
private async Task BisectAndReprobeAsync(IModbusTransport transport,
|
|
(byte Unit, ModbusRegion Region, ushort Start, ushort End) key, CancellationToken ct)
|
|
{
|
|
var fc = key.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
|
|
var mid = (ushort)((key.Start + key.End) / 2);
|
|
var leftEnd = mid;
|
|
var rightStart = (ushort)(mid + 1);
|
|
|
|
var leftFailed = await ProbeFailsAsync(transport, fc, key.Unit, key.Start, leftEnd, ct).ConfigureAwait(false);
|
|
var rightFailed = await ProbeFailsAsync(transport, fc, key.Unit, rightStart, key.End, ct).ConfigureAwait(false);
|
|
|
|
lock (_autoProhibitedLock)
|
|
{
|
|
_autoProhibited.Remove(key);
|
|
if (leftFailed)
|
|
{
|
|
_autoProhibited[(key.Unit, key.Region, key.Start, leftEnd)] = new ProhibitionState
|
|
{
|
|
LastProbedUtc = DateTime.UtcNow,
|
|
SplitPending = leftEnd > key.Start,
|
|
};
|
|
}
|
|
if (rightFailed)
|
|
{
|
|
_autoProhibited[(key.Unit, key.Region, rightStart, key.End)] = new ProhibitionState
|
|
{
|
|
LastProbedUtc = DateTime.UtcNow,
|
|
SplitPending = key.End > rightStart,
|
|
};
|
|
}
|
|
// Both halves succeeded → entry is just removed. The parent prohibition is gone
|
|
// and the next normal scan can re-coalesce across the whole original range.
|
|
}
|
|
|
|
// #153 — log per-half outcome OUTSIDE the lock (logger calls can be expensive).
|
|
// Both halves clear → emit a single combined "fully cleared" line.
|
|
if (!leftFailed && !rightFailed)
|
|
LogProhibitionCleared(key.Unit, key.Region, key.Start, key.End);
|
|
else
|
|
{
|
|
if (!leftFailed)
|
|
LogProhibitionCleared(key.Unit, key.Region, key.Start, leftEnd);
|
|
if (!rightFailed)
|
|
LogProhibitionCleared(key.Unit, key.Region, rightStart, key.End);
|
|
}
|
|
}
|
|
|
|
private async Task<bool> ProbeFailsAsync(IModbusTransport transport, byte fc, byte unit,
|
|
ushort start, ushort end, CancellationToken ct)
|
|
{
|
|
var qty = (ushort)(end - start + 1);
|
|
try
|
|
{
|
|
_ = await ReadRegisterBlockAsync(transport, unit, fc, start, qty, ct).ConfigureAwait(false);
|
|
return false;
|
|
}
|
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
|
catch { return true; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// #143 block-read coalescing planner. Groups eligible tags by (UnitId, Region), sorts
|
|
/// by start address, and merges adjacent / near-adjacent (gap ≤ MaxReadGap) into single
|
|
/// FC03/FC04 reads. Per-block: emit one Modbus PDU, slice the response back into per-tag
|
|
/// values, populate <paramref name="results"/> and the WriteOnChangeOnly cache. Returns
|
|
/// the set of <paramref name="fullReferences"/> indices the planner handled — the
|
|
/// caller falls back to the per-tag path for the rest (arrays, coils, prohibited, unknown).
|
|
/// </summary>
|
|
private async Task<HashSet<int>> ReadCoalescedAsync(
|
|
IModbusTransport transport,
|
|
IReadOnlyList<string> fullReferences,
|
|
DataValueSnapshot[] results,
|
|
DateTime timestamp,
|
|
CancellationToken ct)
|
|
{
|
|
// Eligible: known tag, register region (HoldingRegisters or InputRegisters), scalar
|
|
// (no array, no string), not CoalesceProhibited, not BitInRegister.
|
|
var eligible = new List<(int Index, string Ref, ModbusTagDefinition Tag)>();
|
|
for (var i = 0; i < fullReferences.Count; i++)
|
|
{
|
|
if (!_tagsByName.TryGetValue(fullReferences[i], out var tag)) continue;
|
|
if (tag.CoalesceProhibited) continue;
|
|
if (tag.ArrayCount.HasValue) continue;
|
|
if (tag.Region is not (ModbusRegion.HoldingRegisters or ModbusRegion.InputRegisters)) continue;
|
|
if (tag.DataType is ModbusDataType.String or ModbusDataType.BitInRegister) continue;
|
|
eligible.Add((i, fullReferences[i], tag));
|
|
}
|
|
if (eligible.Count == 0) return new HashSet<int>();
|
|
|
|
var handled = new HashSet<int>();
|
|
|
|
// Group by (UnitId, Region) — coalescing across slaves or regions is unsafe.
|
|
foreach (var group in eligible.GroupBy(e => (Unit: ResolveUnitId(e.Tag), e.Tag.Region)))
|
|
{
|
|
var fc = group.Key.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
|
|
var cap = _options.MaxRegistersPerRead == 0 ? (ushort)125 : _options.MaxRegistersPerRead;
|
|
var sorted = group.OrderBy(e => e.Tag.Address).ToList();
|
|
|
|
// Build merged blocks. A "block" is (start, lastEnd, members[]) where lastEnd is
|
|
// the inclusive end address of the last tag's register span. A new tag joins the
|
|
// block if its start ≤ lastEnd + 1 + MaxReadGap AND the resulting span ≤ cap.
|
|
var blocks = new List<(ushort Start, ushort End, List<(int Index, ModbusTagDefinition Tag)> Members)>();
|
|
foreach (var (idx, _, tag) in sorted)
|
|
{
|
|
var tagStart = tag.Address;
|
|
var tagEnd = (ushort)(tag.Address + RegisterCount(tag) - 1);
|
|
if (blocks.Count > 0)
|
|
{
|
|
var last = blocks[^1];
|
|
var gap = tagStart - last.End - 1;
|
|
var newEnd = Math.Max(tagEnd, last.End);
|
|
var newSpan = newEnd - last.Start + 1;
|
|
// #148 — skip merges that would re-attempt a known-bad range. The
|
|
// per-tag fallback will read each member individually instead.
|
|
var crossesProhibition = RangeIsAutoProhibited(group.Key.Unit, group.Key.Region, last.Start, (ushort)newEnd);
|
|
if (gap <= _options.MaxReadGap && newSpan <= cap && !crossesProhibition)
|
|
{
|
|
last.Members.Add((idx, tag));
|
|
blocks[^1] = (last.Start, (ushort)newEnd, last.Members);
|
|
continue;
|
|
}
|
|
}
|
|
blocks.Add((tagStart, tagEnd, new List<(int, ModbusTagDefinition)> { (idx, tag) }));
|
|
}
|
|
|
|
// Issue one PDU per block. On a Modbus-level exception (illegal data address /
|
|
// protected register), record the range as auto-prohibited (#148), leave the
|
|
// member indices UNhandled, and let the per-tag fallback in ReadAsync read each
|
|
// surviving address individually. On transport-level failure (timeout / socket
|
|
// drop) mark members Bad and short-circuit the per-tag fallback (hitting the
|
|
// dead socket again won't help). #150 bisection narrows the prohibition over
|
|
// subsequent re-probe ticks.
|
|
foreach (var block in blocks)
|
|
{
|
|
if (block.Members.Count == 1)
|
|
{
|
|
// Lone tag — let the per-tag path handle it for symmetry with WriteOnChange
|
|
// cache invalidation. Costs nothing because one tag = one PDU either way.
|
|
continue;
|
|
}
|
|
|
|
var qty = (ushort)(block.End - block.Start + 1);
|
|
try
|
|
{
|
|
var data = await ReadRegisterBlockAsync(transport, group.Key.Unit, fc, block.Start, qty, ct).ConfigureAwait(false);
|
|
foreach (var (idx, tag) in block.Members)
|
|
{
|
|
var sliceOffsetBytes = (tag.Address - block.Start) * 2;
|
|
var sliceLenBytes = RegisterCount(tag) * 2;
|
|
var value = DecodeRegister(data.AsSpan(sliceOffsetBytes, sliceLenBytes), tag);
|
|
results[idx] = new DataValueSnapshot(value, 0u, timestamp, timestamp);
|
|
handled.Add(idx);
|
|
InvalidateWriteCacheIfDiverged(fullReferences[idx], value);
|
|
}
|
|
WriteHealth(new DriverHealth(DriverState.Healthy, timestamp, null));
|
|
}
|
|
catch (ModbusException mex)
|
|
{
|
|
// #148 — record the failed range so the planner stops re-coalescing across
|
|
// it on subsequent scans. The members are intentionally NOT added to the
|
|
// handled-set: ReadAsync's per-tag fallback runs them individually in the
|
|
// same scan, so healthy tags around the protected hole keep working without
|
|
// operator intervention. Members that ARE the protected register will fail
|
|
// again at single-tag granularity and surface the per-tag exception code
|
|
// naturally — the block-level mex isn't propagated.
|
|
RecordAutoProhibition(group.Key.Unit, group.Key.Region, block.Start, block.End);
|
|
WriteHealth(new DriverHealth(DriverState.Degraded, ReadHealth().LastSuccessfulRead, mex.Message));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Communication failures (timeout, socket drop) aren't a structural reason
|
|
// to prohibit the range — the same coalesced read might succeed once the
|
|
// transport recovers. Mark members Bad for this scan but don't auto-prohibit
|
|
// and don't deflect to per-tag fallback (which would just hit the same dead
|
|
// socket).
|
|
foreach (var (idx, _) in block.Members)
|
|
{
|
|
results[idx] = new DataValueSnapshot(null, StatusBadCommunicationError, null, timestamp);
|
|
handled.Add(idx);
|
|
}
|
|
WriteHealth(new DriverHealth(DriverState.Degraded, ReadHealth().LastSuccessfulRead, ex.Message));
|
|
}
|
|
}
|
|
}
|
|
return handled;
|
|
}
|
|
|
|
private void InvalidateWriteCacheIfDiverged(string fullRef, object value)
|
|
{
|
|
if (!_options.WriteOnChangeOnly) return;
|
|
lock (_lastWrittenLock)
|
|
{
|
|
if (_lastWrittenByRef.TryGetValue(fullRef, out var prev) && !Equals(prev, value))
|
|
_lastWrittenByRef.Remove(fullRef);
|
|
}
|
|
}
|
|
|
|
|
|
private async Task<byte[]> ReadRegisterBlockAsync(
|
|
IModbusTransport transport, byte unitId, byte fc, ushort address, ushort quantity, CancellationToken ct)
|
|
{
|
|
var pdu = new byte[] { fc, (byte)(address >> 8), (byte)(address & 0xFF),
|
|
(byte)(quantity >> 8), (byte)(quantity & 0xFF) };
|
|
var resp = await transport.SendAsync(unitId, pdu, ct).ConfigureAwait(false);
|
|
// resp = [fc][byte-count][data...] — validate before indexing to surface a clean error
|
|
// rather than an IndexOutOfRangeException when a device returns a truncated PDU.
|
|
// Driver.Modbus-005: guard resp.Length >= 2 (fc + byte-count) and that the payload is
|
|
// at least as long as the declared byte-count, matching the quantity we requested.
|
|
if (resp.Length < 2)
|
|
throw new InvalidDataException(
|
|
$"Modbus register response too short: expected at least 2 bytes (fc+bytecount), got {resp.Length}");
|
|
if (resp.Length < 2 + resp[1])
|
|
throw new InvalidDataException(
|
|
$"Modbus register response truncated: byte-count field declares {resp[1]} bytes but only {resp.Length - 2} available");
|
|
var expectedByteCount = quantity * 2;
|
|
if (resp[1] != expectedByteCount)
|
|
throw new InvalidDataException(
|
|
$"Modbus register response byte-count mismatch: requested {quantity} registers ({expectedByteCount} bytes), got {resp[1]} bytes");
|
|
var data = new byte[resp[1]];
|
|
Buffer.BlockCopy(resp, 2, data, 0, resp[1]);
|
|
return data;
|
|
}
|
|
|
|
private async Task<byte[]> ReadBitBlockAsync(
|
|
IModbusTransport transport, byte unitId, byte fc, ushort address, ushort qty, CancellationToken ct)
|
|
{
|
|
var pdu = new byte[] { fc, (byte)(address >> 8), (byte)(address & 0xFF),
|
|
(byte)(qty >> 8), (byte)(qty & 0xFF) };
|
|
var resp = await transport.SendAsync(unitId, pdu, ct).ConfigureAwait(false);
|
|
// Driver.Modbus-005: validate the response is structurally sound before indexing.
|
|
if (resp.Length < 2)
|
|
throw new InvalidDataException(
|
|
$"Modbus bit response too short: expected at least 2 bytes (fc+bytecount), got {resp.Length}");
|
|
if (resp.Length < 2 + resp[1])
|
|
throw new InvalidDataException(
|
|
$"Modbus bit response truncated: byte-count field declares {resp[1]} bytes but only {resp.Length - 2} available");
|
|
var expectedByteCount = (qty + 7) / 8;
|
|
if (resp[1] < expectedByteCount)
|
|
throw new InvalidDataException(
|
|
$"Modbus bit response byte-count mismatch: requested {qty} bits ({expectedByteCount} bytes), got {resp[1]} bytes");
|
|
var bitmap = new byte[resp[1]];
|
|
Buffer.BlockCopy(resp, 2, bitmap, 0, resp[1]);
|
|
return bitmap;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Auto-chunk coil-array reads above MaxCoilsPerRead. Reassembles per-chunk bitmaps into
|
|
/// one logical bitmap byte array sized for the full <paramref name="totalBits"/>; the
|
|
/// downstream <see cref="DecodeBitArray"/> walks bits LSB-first the same way it would
|
|
/// for a single-chunk response.
|
|
/// </summary>
|
|
private async Task<byte[]> ReadBitBlockChunkedAsync(
|
|
IModbusTransport transport, byte unitId, byte fc, ushort address, int totalBits, ushort cap, CancellationToken ct)
|
|
{
|
|
var assembled = new byte[(totalBits + 7) / 8];
|
|
var done = 0;
|
|
while (done < totalBits)
|
|
{
|
|
var chunk = (ushort)Math.Min(cap, totalBits - done);
|
|
var chunkBitmap = await ReadBitBlockAsync(transport, unitId, fc, (ushort)(address + done), chunk, ct).ConfigureAwait(false);
|
|
// Re-pack per-chunk LSB-first bits into the assembled bitmap at the right offset.
|
|
for (var i = 0; i < chunk; i++)
|
|
{
|
|
if (((chunkBitmap[i / 8] >> (i % 8)) & 0x01) == 0) continue;
|
|
var dest = done + i;
|
|
assembled[dest / 8] |= (byte)(1 << (dest % 8));
|
|
}
|
|
done += chunk;
|
|
}
|
|
return assembled;
|
|
}
|
|
|
|
private async Task<byte[]> ReadRegisterBlockChunkedAsync(
|
|
IModbusTransport transport, byte unitId, byte fc, ushort address, ushort totalRegs, ushort cap, CancellationToken ct)
|
|
{
|
|
var assembled = new byte[totalRegs * 2];
|
|
ushort done = 0;
|
|
while (done < totalRegs)
|
|
{
|
|
var chunk = (ushort)Math.Min(cap, totalRegs - done);
|
|
var chunkBytes = await ReadRegisterBlockAsync(transport, unitId, fc, (ushort)(address + done), chunk, ct).ConfigureAwait(false);
|
|
Buffer.BlockCopy(chunkBytes, 0, assembled, done * 2, chunkBytes.Length);
|
|
done += chunk;
|
|
}
|
|
return assembled;
|
|
}
|
|
|
|
// ---- IWritable ----
|
|
|
|
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
|
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
|
{
|
|
var transport = RequireTransport();
|
|
var results = new WriteResult[writes.Count];
|
|
for (var i = 0; i < writes.Count; i++)
|
|
{
|
|
var w = writes[i];
|
|
if (!_tagsByName.TryGetValue(w.FullReference, out var tag))
|
|
{
|
|
results[i] = new WriteResult(StatusBadNodeIdUnknown);
|
|
continue;
|
|
}
|
|
if (!tag.Writable || tag.Region is ModbusRegion.DiscreteInputs or ModbusRegion.InputRegisters)
|
|
{
|
|
results[i] = new WriteResult(StatusBadNotWritable);
|
|
continue;
|
|
}
|
|
// #141 WriteOnChangeOnly suppression: skip the wire round-trip when the same value
|
|
// was already successfully written and no read since has invalidated the cache.
|
|
if (_options.WriteOnChangeOnly && IsRedundantWrite(w.FullReference, w.Value))
|
|
{
|
|
results[i] = new WriteResult(0u);
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
await WriteOneAsync(transport, tag, w.Value, cancellationToken).ConfigureAwait(false);
|
|
results[i] = new WriteResult(0u);
|
|
if (_options.WriteOnChangeOnly)
|
|
lock (_lastWrittenLock) _lastWrittenByRef[w.FullReference] = w.Value;
|
|
}
|
|
catch (ModbusException mex)
|
|
{
|
|
results[i] = new WriteResult(MapModbusExceptionToStatus(mex.ExceptionCode));
|
|
}
|
|
catch (Exception)
|
|
{
|
|
results[i] = new WriteResult(StatusBadInternalError);
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
private bool IsRedundantWrite(string tagRef, object? value)
|
|
{
|
|
lock (_lastWrittenLock)
|
|
{
|
|
if (!_lastWrittenByRef.TryGetValue(tagRef, out var prev)) return false;
|
|
// Object.Equals handles boxed-numeric equality (5 == 5 even if one was short and
|
|
// one int through boxing). For arrays we deliberately don't suppress — equality
|
|
// semantics on arrays are reference-only so the cache miss is the safer answer.
|
|
if (prev is null || value is null) return Equals(prev, value);
|
|
if (prev is Array || value is Array) return false;
|
|
return prev.Equals(value);
|
|
}
|
|
}
|
|
|
|
// BitInRegister writes need a read-modify-write against the full holding register. The
|
|
// per-register lock (declared at the top of the class) keeps concurrent bit-write callers
|
|
// from stomping on each other — Write bit 0 and Write bit 5 targeting the same register
|
|
// can arrive on separate subscriber threads, and without serialising the RMW the
|
|
// second-to-commit value wins + the first bit update is lost.
|
|
private SemaphoreSlim GetRmwLock(ushort address) =>
|
|
_rmwLocks.GetOrAdd(address, _ => new SemaphoreSlim(1, 1));
|
|
|
|
private async Task WriteOneAsync(IModbusTransport transport, ModbusTagDefinition tag, object? value, CancellationToken ct)
|
|
{
|
|
// BitInRegister → RMW dispatch ahead of the normal encode path so the lock + read-modify-
|
|
// write sequence doesn't hit EncodeRegister's defensive throw.
|
|
if (tag.DataType == ModbusDataType.BitInRegister &&
|
|
tag.Region is ModbusRegion.HoldingRegisters)
|
|
{
|
|
await WriteBitInRegisterAsync(transport, tag, value, ct).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
switch (tag.Region)
|
|
{
|
|
case ModbusRegion.Coils:
|
|
{
|
|
if (!tag.ArrayCount.HasValue && !_options.UseFC15ForSingleCoilWrites)
|
|
{
|
|
var on = Convert.ToBoolean(value);
|
|
var pdu = new byte[] { 0x05, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
|
|
on ? (byte)0xFF : (byte)0x00, 0x00 };
|
|
await transport.SendAsync(ResolveUnitId(tag), pdu, ct).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
// FC15 path: either an explicit array, or UseFC15ForSingleCoilWrites=true forced
|
|
// it for a scalar (synthesise a 1-element bool[] from the scalar value).
|
|
var arrayLen = tag.ArrayCount ?? 1;
|
|
if (!tag.ArrayCount.HasValue)
|
|
value = new[] { Convert.ToBoolean(value) };
|
|
// FC15 — Write Multiple Coils. Pack the bool[] into LSB-first bitmap.
|
|
var values = ToBoolArray(value, arrayLen, tag.Name);
|
|
var byteCount = (values.Length + 7) / 8;
|
|
var bitmap = new byte[byteCount];
|
|
for (var i = 0; i < values.Length; i++)
|
|
if (values[i]) bitmap[i / 8] |= (byte)(1 << (i % 8));
|
|
var qty = (ushort)values.Length;
|
|
var pdu15 = new byte[6 + 1 + byteCount];
|
|
pdu15[0] = 0x0F;
|
|
pdu15[1] = (byte)(tag.Address >> 8); pdu15[2] = (byte)(tag.Address & 0xFF);
|
|
pdu15[3] = (byte)(qty >> 8); pdu15[4] = (byte)(qty & 0xFF);
|
|
pdu15[5] = (byte)byteCount;
|
|
Buffer.BlockCopy(bitmap, 0, pdu15, 6, byteCount);
|
|
await transport.SendAsync(ResolveUnitId(tag), pdu15, ct).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
case ModbusRegion.HoldingRegisters:
|
|
{
|
|
var bytes = tag.ArrayCount.HasValue
|
|
? EncodeRegisterArray(value, tag)
|
|
: EncodeRegister(value, tag);
|
|
|
|
if (bytes.Length == 2 && !tag.ArrayCount.HasValue && !_options.UseFC16ForSingleRegisterWrites)
|
|
{
|
|
// FC06 fast-path for single-register scalar writes only. Arrays always use
|
|
// FC16 even when the array is one element wide, because the encoder shape
|
|
// may need it. UseFC16ForSingleRegisterWrites=true forces FC16 even here for
|
|
// PLCs that only accept the multi-write codes.
|
|
var pdu = new byte[] { 0x06, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
|
|
bytes[0], bytes[1] };
|
|
await transport.SendAsync(ResolveUnitId(tag), pdu, ct).ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
// FC 16 (Write Multiple Registers) for 32-bit / 64-bit / array / string types.
|
|
var qty = (ushort)(bytes.Length / 2);
|
|
var writeCap = _options.MaxRegistersPerWrite == 0 ? (ushort)123 : _options.MaxRegistersPerWrite;
|
|
if (qty > writeCap)
|
|
throw new InvalidOperationException(
|
|
$"Write of {qty} registers to {tag.Name} exceeds MaxRegistersPerWrite={writeCap}. " +
|
|
$"Split the tag (e.g. shorter StringLength or smaller ArrayCount) — partial FC16 chunks would lose atomicity.");
|
|
var pdu = new byte[6 + 1 + bytes.Length];
|
|
pdu[0] = 0x10;
|
|
pdu[1] = (byte)(tag.Address >> 8); pdu[2] = (byte)(tag.Address & 0xFF);
|
|
pdu[3] = (byte)(qty >> 8); pdu[4] = (byte)(qty & 0xFF);
|
|
pdu[5] = (byte)bytes.Length;
|
|
Buffer.BlockCopy(bytes, 0, pdu, 6, bytes.Length);
|
|
await transport.SendAsync(ResolveUnitId(tag), pdu, ct).ConfigureAwait(false);
|
|
}
|
|
return;
|
|
}
|
|
default:
|
|
throw new InvalidOperationException($"Writes not supported for region {tag.Region}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Encode an array-typed write value into a contiguous byte block by encoding each
|
|
/// element with the scalar codec. Caller submits IList / Array of the element CLR type.
|
|
/// </summary>
|
|
private static byte[] EncodeRegisterArray(object? value, ModbusTagDefinition tag)
|
|
{
|
|
var count = tag.ArrayCount!.Value;
|
|
if (value is not System.Collections.IList list || list.Count != count)
|
|
throw new InvalidOperationException(
|
|
$"Array write to {tag.Name} expects an IList of length {count}; got {value?.GetType().Name ?? "null"}");
|
|
|
|
var elementBytes = ElementByteCount(tag);
|
|
var result = new byte[count * elementBytes];
|
|
for (var i = 0; i < count; i++)
|
|
{
|
|
var element = list[i];
|
|
var encoded = EncodeRegister(element, tag);
|
|
if (encoded.Length != elementBytes)
|
|
throw new InvalidOperationException(
|
|
$"Encoder returned {encoded.Length} bytes for element {i} of {tag.Name}, expected {elementBytes}");
|
|
Buffer.BlockCopy(encoded, 0, result, i * elementBytes, elementBytes);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private static bool[] ToBoolArray(object? value, int expectedCount, string tagName)
|
|
{
|
|
if (value is bool[] direct && direct.Length == expectedCount) return direct;
|
|
if (value is System.Collections.IList list && list.Count == expectedCount)
|
|
{
|
|
var arr = new bool[expectedCount];
|
|
for (var i = 0; i < expectedCount; i++) arr[i] = Convert.ToBoolean(list[i]);
|
|
return arr;
|
|
}
|
|
throw new InvalidOperationException(
|
|
$"Coil-array write to {tagName} expects a bool[] (or convertible IList) of length {expectedCount}; got {value?.GetType().Name ?? "null"}");
|
|
}
|
|
|
|
private static int ElementByteCount(ModbusTagDefinition tag) => tag.DataType switch
|
|
{
|
|
ModbusDataType.Int16 or ModbusDataType.UInt16 or ModbusDataType.Bcd16 => 2,
|
|
ModbusDataType.Int32 or ModbusDataType.UInt32 or ModbusDataType.Float32 or ModbusDataType.Bcd32 => 4,
|
|
ModbusDataType.Int64 or ModbusDataType.UInt64 or ModbusDataType.Float64 => 8,
|
|
_ => throw new InvalidOperationException($"Element byte count not defined for {tag.DataType} in array context"),
|
|
};
|
|
|
|
/// <summary>
|
|
/// Read-modify-write one bit in a holding register. FC03 → bit-swap → FC06. Serialised
|
|
/// against other bit writes targeting the same register via <see cref="GetRmwLock"/>.
|
|
/// </summary>
|
|
private async Task WriteBitInRegisterAsync(
|
|
IModbusTransport transport, ModbusTagDefinition tag, object? value, CancellationToken ct)
|
|
{
|
|
var bit = tag.BitIndex;
|
|
if (bit > 15)
|
|
throw new InvalidOperationException(
|
|
$"BitInRegister bit index {bit} out of range (0-15) for tag {tag.Name}.");
|
|
var on = Convert.ToBoolean(value);
|
|
|
|
var rmwLock = GetRmwLock(tag.Address);
|
|
await rmwLock.WaitAsync(ct).ConfigureAwait(false);
|
|
try
|
|
{
|
|
// FC03 read 1 holding register at tag.Address.
|
|
var readPdu = new byte[] { 0x03, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), 0x00, 0x01 };
|
|
var readResp = await transport.SendAsync(ResolveUnitId(tag), readPdu, ct).ConfigureAwait(false);
|
|
// resp = [fc][byte-count=2][hi][lo]
|
|
var current = (ushort)((readResp[2] << 8) | readResp[3]);
|
|
|
|
var updated = on
|
|
? (ushort)(current | (1 << bit))
|
|
: (ushort)(current & ~(1 << bit));
|
|
|
|
// FC06 write single holding register.
|
|
var writePdu = new byte[] { 0x06, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
|
|
(byte)(updated >> 8), (byte)(updated & 0xFF) };
|
|
await transport.SendAsync(ResolveUnitId(tag), writePdu, ct).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
rmwLock.Release();
|
|
}
|
|
}
|
|
|
|
// ---- ISubscribable (polling overlay via shared engine) ----
|
|
|
|
public Task<ISubscriptionHandle> SubscribeAsync(
|
|
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
|
|
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
|
|
|
|
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
|
{
|
|
_poll.Unsubscribe(handle);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
// ---- IHostConnectivityProbe ----
|
|
|
|
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
|
|
{
|
|
lock (_probeLock)
|
|
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Host identifier surfaced to <c>IHostConnectivityProbe.GetHostStatuses</c> and the Admin UI.
|
|
/// Formatted as <c>host:port</c> so multiple Modbus drivers in the same server disambiguate
|
|
/// by endpoint without needing the driver-instance-id in the Admin dashboard.
|
|
/// </summary>
|
|
public string HostName => $"{_options.Host}:{_options.Port}";
|
|
|
|
private async Task ProbeLoopAsync(CancellationToken ct)
|
|
{
|
|
var transport = _transport; // captured reference; disposal tears the loop down via ct
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
var success = false;
|
|
try
|
|
{
|
|
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
probeCts.CancelAfter(_options.Probe.Timeout);
|
|
var pdu = new byte[] { 0x03,
|
|
(byte)(_options.Probe.ProbeAddress >> 8),
|
|
(byte)(_options.Probe.ProbeAddress & 0xFF), 0x00, 0x01 };
|
|
_ = await transport!.SendAsync(_options.UnitId, pdu, probeCts.Token).ConfigureAwait(false);
|
|
success = true;
|
|
}
|
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
|
{
|
|
return;
|
|
}
|
|
catch
|
|
{
|
|
// transport / timeout / exception PDU — treated as Stopped below
|
|
}
|
|
|
|
TransitionTo(success ? HostState.Running : HostState.Stopped);
|
|
|
|
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
|
|
catch (OperationCanceledException) { return; }
|
|
}
|
|
}
|
|
|
|
private void TransitionTo(HostState newState)
|
|
{
|
|
HostState old;
|
|
lock (_probeLock)
|
|
{
|
|
old = _hostState;
|
|
if (old == newState) return;
|
|
_hostState = newState;
|
|
_hostStateChangedUtc = DateTime.UtcNow;
|
|
}
|
|
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
|
|
}
|
|
|
|
// ---- codec ----
|
|
|
|
/// <summary>
|
|
/// How many 16-bit registers a given tag occupies. Accounts for multi-register logical
|
|
/// types (Int32/Float32 = 2 regs, Int64/Float64 = 4 regs) and for strings (rounded up
|
|
/// from 2 chars per register).
|
|
/// </summary>
|
|
internal static ushort RegisterCount(ModbusTagDefinition tag) => tag.DataType switch
|
|
{
|
|
ModbusDataType.Int16 or ModbusDataType.UInt16 or ModbusDataType.BitInRegister or ModbusDataType.Bcd16 => 1,
|
|
ModbusDataType.Int32 or ModbusDataType.UInt32 or ModbusDataType.Float32 or ModbusDataType.Bcd32 => 2,
|
|
ModbusDataType.Int64 or ModbusDataType.UInt64 or ModbusDataType.Float64 => 4,
|
|
ModbusDataType.String => (ushort)((tag.StringLength + 1) / 2), // 2 chars per register
|
|
_ => throw new InvalidOperationException($"Non-register data type {tag.DataType}"),
|
|
};
|
|
|
|
/// <summary>
|
|
/// Re-order the input bytes into the big-endian (ABCD) layout the decoders expect.
|
|
/// The four orders refer to how bytes A, B, C, D appear on the wire when reading a
|
|
/// 32-bit value from two consecutive registers (extends pairwise for 64-bit / 4 regs):
|
|
/// <list type="bullet">
|
|
/// <item><b>BigEndian (ABCD)</b>: bytes as-is — Modbus spec default.</item>
|
|
/// <item><b>WordSwap (CDAB)</b>: swap word pairs (full register reversal across the value).</item>
|
|
/// <item><b>ByteSwap (BADC)</b>: swap bytes within each register.</item>
|
|
/// <item><b>FullReverse (DCBA)</b>: full byte reversal — equivalent to little-endian.</item>
|
|
/// </list>
|
|
/// </summary>
|
|
private static byte[] NormalizeWordOrder(ReadOnlySpan<byte> data, ModbusByteOrder order)
|
|
{
|
|
if (order == ModbusByteOrder.BigEndian) return data.ToArray();
|
|
|
|
var result = new byte[data.Length];
|
|
var registers = data.Length / 2;
|
|
|
|
switch (order)
|
|
{
|
|
case ModbusByteOrder.WordSwap:
|
|
// Reverse register order; bytes within each register stay big-endian.
|
|
for (var word = 0; word < registers; word++)
|
|
{
|
|
var srcWord = registers - 1 - word;
|
|
result[word * 2] = data[srcWord * 2];
|
|
result[word * 2 + 1] = data[srcWord * 2 + 1];
|
|
}
|
|
break;
|
|
case ModbusByteOrder.ByteSwap:
|
|
// Keep register order, swap two bytes within each register.
|
|
for (var word = 0; word < registers; word++)
|
|
{
|
|
result[word * 2] = data[word * 2 + 1];
|
|
result[word * 2 + 1] = data[word * 2];
|
|
}
|
|
break;
|
|
case ModbusByteOrder.FullReverse:
|
|
// Full byte-by-byte reversal — equivalent to interpreting the value little-endian.
|
|
for (var i = 0; i < data.Length; i++)
|
|
result[i] = data[data.Length - 1 - i];
|
|
break;
|
|
default:
|
|
throw new InvalidOperationException($"Unhandled byte order {order}");
|
|
}
|
|
return result;
|
|
}
|
|
|
|
internal static object DecodeRegister(ReadOnlySpan<byte> data, ModbusTagDefinition tag)
|
|
{
|
|
switch (tag.DataType)
|
|
{
|
|
case ModbusDataType.Int16: return BinaryPrimitives.ReadInt16BigEndian(data);
|
|
case ModbusDataType.UInt16: return BinaryPrimitives.ReadUInt16BigEndian(data);
|
|
case ModbusDataType.Bcd16:
|
|
{
|
|
var raw = BinaryPrimitives.ReadUInt16BigEndian(data);
|
|
return (int)DecodeBcd(raw, nibbles: 4);
|
|
}
|
|
case ModbusDataType.Bcd32:
|
|
{
|
|
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
|
var raw = BinaryPrimitives.ReadUInt32BigEndian(b);
|
|
return (int)DecodeBcd(raw, nibbles: 8);
|
|
}
|
|
case ModbusDataType.BitInRegister:
|
|
{
|
|
var raw = BinaryPrimitives.ReadUInt16BigEndian(data);
|
|
return (raw & (1 << tag.BitIndex)) != 0;
|
|
}
|
|
case ModbusDataType.Int32:
|
|
{
|
|
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
|
return BinaryPrimitives.ReadInt32BigEndian(b);
|
|
}
|
|
case ModbusDataType.UInt32:
|
|
{
|
|
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
|
return BinaryPrimitives.ReadUInt32BigEndian(b);
|
|
}
|
|
case ModbusDataType.Float32:
|
|
{
|
|
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
|
return BinaryPrimitives.ReadSingleBigEndian(b);
|
|
}
|
|
case ModbusDataType.Int64:
|
|
{
|
|
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
|
return BinaryPrimitives.ReadInt64BigEndian(b);
|
|
}
|
|
case ModbusDataType.UInt64:
|
|
{
|
|
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
|
return BinaryPrimitives.ReadUInt64BigEndian(b);
|
|
}
|
|
case ModbusDataType.Float64:
|
|
{
|
|
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
|
return BinaryPrimitives.ReadDoubleBigEndian(b);
|
|
}
|
|
case ModbusDataType.String:
|
|
{
|
|
// ASCII, 2 chars per register. HighByteFirst (standard) packs the first char in
|
|
// the high byte of each register; LowByteFirst (DL205/DL260) packs the first char
|
|
// in the low byte. Respect StringLength (truncate nul-padded regions).
|
|
var chars = new char[tag.StringLength];
|
|
for (var i = 0; i < tag.StringLength; i++)
|
|
{
|
|
var regIdx = i / 2;
|
|
var highByte = data[regIdx * 2];
|
|
var lowByte = data[regIdx * 2 + 1];
|
|
byte b;
|
|
if (tag.StringByteOrder == ModbusStringByteOrder.HighByteFirst)
|
|
b = (i % 2 == 0) ? highByte : lowByte;
|
|
else
|
|
b = (i % 2 == 0) ? lowByte : highByte;
|
|
if (b == 0) return new string(chars, 0, i);
|
|
chars[i] = (char)b;
|
|
}
|
|
return new string(chars);
|
|
}
|
|
default:
|
|
throw new InvalidOperationException($"Non-register data type {tag.DataType}");
|
|
}
|
|
}
|
|
|
|
internal static byte[] EncodeRegister(object? value, ModbusTagDefinition tag)
|
|
{
|
|
switch (tag.DataType)
|
|
{
|
|
case ModbusDataType.Int16:
|
|
{
|
|
var v = Convert.ToInt16(value);
|
|
var b = new byte[2]; BinaryPrimitives.WriteInt16BigEndian(b, v); return b;
|
|
}
|
|
case ModbusDataType.UInt16:
|
|
{
|
|
var v = Convert.ToUInt16(value);
|
|
var b = new byte[2]; BinaryPrimitives.WriteUInt16BigEndian(b, v); return b;
|
|
}
|
|
case ModbusDataType.Bcd16:
|
|
{
|
|
var v = Convert.ToUInt32(value);
|
|
if (v > 9999) throw new OverflowException($"BCD16 value {v} exceeds 4 decimal digits");
|
|
var raw = (ushort)EncodeBcd(v, nibbles: 4);
|
|
var b = new byte[2]; BinaryPrimitives.WriteUInt16BigEndian(b, raw); return b;
|
|
}
|
|
case ModbusDataType.Bcd32:
|
|
{
|
|
var v = Convert.ToUInt32(value);
|
|
if (v > 99_999_999u) throw new OverflowException($"BCD32 value {v} exceeds 8 decimal digits");
|
|
var raw = EncodeBcd(v, nibbles: 8);
|
|
var b = new byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, raw);
|
|
return NormalizeWordOrder(b, tag.ByteOrder);
|
|
}
|
|
case ModbusDataType.Int32:
|
|
{
|
|
var v = Convert.ToInt32(value);
|
|
var b = new byte[4]; BinaryPrimitives.WriteInt32BigEndian(b, v);
|
|
return NormalizeWordOrder(b, tag.ByteOrder);
|
|
}
|
|
case ModbusDataType.UInt32:
|
|
{
|
|
var v = Convert.ToUInt32(value);
|
|
var b = new byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, v);
|
|
return NormalizeWordOrder(b, tag.ByteOrder);
|
|
}
|
|
case ModbusDataType.Float32:
|
|
{
|
|
var v = Convert.ToSingle(value);
|
|
var b = new byte[4]; BinaryPrimitives.WriteSingleBigEndian(b, v);
|
|
return NormalizeWordOrder(b, tag.ByteOrder);
|
|
}
|
|
case ModbusDataType.Int64:
|
|
{
|
|
var v = Convert.ToInt64(value);
|
|
var b = new byte[8]; BinaryPrimitives.WriteInt64BigEndian(b, v);
|
|
return NormalizeWordOrder(b, tag.ByteOrder);
|
|
}
|
|
case ModbusDataType.UInt64:
|
|
{
|
|
var v = Convert.ToUInt64(value);
|
|
var b = new byte[8]; BinaryPrimitives.WriteUInt64BigEndian(b, v);
|
|
return NormalizeWordOrder(b, tag.ByteOrder);
|
|
}
|
|
case ModbusDataType.Float64:
|
|
{
|
|
var v = Convert.ToDouble(value);
|
|
var b = new byte[8]; BinaryPrimitives.WriteDoubleBigEndian(b, v);
|
|
return NormalizeWordOrder(b, tag.ByteOrder);
|
|
}
|
|
case ModbusDataType.String:
|
|
{
|
|
var s = Convert.ToString(value) ?? string.Empty;
|
|
var regs = (tag.StringLength + 1) / 2;
|
|
var b = new byte[regs * 2];
|
|
for (var i = 0; i < tag.StringLength && i < s.Length; i++)
|
|
{
|
|
var regIdx = i / 2;
|
|
var destIdx = tag.StringByteOrder == ModbusStringByteOrder.HighByteFirst
|
|
? (i % 2 == 0 ? regIdx * 2 : regIdx * 2 + 1)
|
|
: (i % 2 == 0 ? regIdx * 2 + 1 : regIdx * 2);
|
|
b[destIdx] = (byte)s[i];
|
|
}
|
|
// remaining bytes stay 0 — nul-padded per PLC convention
|
|
return b;
|
|
}
|
|
case ModbusDataType.BitInRegister:
|
|
// Reached only if BitInRegister is somehow passed outside the HoldingRegisters
|
|
// path. Normal BitInRegister writes dispatch through WriteBitInRegisterAsync via
|
|
// the RMW shortcut in WriteOneAsync.
|
|
throw new InvalidOperationException(
|
|
"BitInRegister writes must go through WriteBitInRegisterAsync (HoldingRegisters region only).");
|
|
default:
|
|
throw new InvalidOperationException($"Non-register data type {tag.DataType}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Map a Modbus logical type to the driver-agnostic <see cref="DriverDataType"/> used
|
|
/// by the address-space builder.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Driver.Modbus-007 — Int64 / UInt64 surfacing limitation:</b>
|
|
/// <see cref="DriverDataType"/> does not yet include an Int64 enum member, so 64-bit
|
|
/// Modbus tags currently surface as <see cref="DriverDataType.Int32"/> on the OPC UA
|
|
/// address space. The wire codec (<c>DecodeRegister</c> / <c>EncodeRegister</c>) is
|
|
/// correct — values round-trip as 64-bit <c>long</c> / <c>ulong</c> through
|
|
/// <c>ReadAsync</c> / <c>WriteAsync</c>. Only the variable node's <c>DataType</c>
|
|
/// attribute is misreported. Clients that consume the type advertisement will see a
|
|
/// type/value mismatch for values outside the 32-bit signed range. Operators
|
|
/// configuring <c>I_64</c> / <c>UI_64</c> tags should be aware of this until the
|
|
/// tracked <c>DriverDataType.Int64</c> follow-up ships.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static DriverDataType MapDataType(ModbusDataType t) => t switch
|
|
{
|
|
ModbusDataType.Bool or ModbusDataType.BitInRegister => DriverDataType.Boolean,
|
|
ModbusDataType.Int16 or ModbusDataType.Int32 => DriverDataType.Int32,
|
|
ModbusDataType.UInt16 or ModbusDataType.UInt32 => DriverDataType.Int32,
|
|
// Driver.Modbus-007: Int64 / UInt64 currently surface as Int32 because DriverDataType
|
|
// has no Int64 member yet. The wire codec preserves the 64-bit value; only the OPC UA
|
|
// node's declared DataType is widened. Tracked for a follow-up that adds the enum
|
|
// member + node-type advertisement.
|
|
ModbusDataType.Int64 or ModbusDataType.UInt64 => DriverDataType.Int32,
|
|
ModbusDataType.Float32 => DriverDataType.Float32,
|
|
ModbusDataType.Float64 => DriverDataType.Float64,
|
|
ModbusDataType.String => DriverDataType.String,
|
|
ModbusDataType.Bcd16 or ModbusDataType.Bcd32 => DriverDataType.Int32,
|
|
_ => DriverDataType.Int32,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Decode an N-nibble binary-coded-decimal value. Each nibble of <paramref name="raw"/>
|
|
/// encodes one decimal digit (most-significant nibble first). Rejects nibbles > 9 —
|
|
/// the hardware sometimes produces garbage during transitions and silent non-BCD reads
|
|
/// would quietly corrupt the caller's data.
|
|
/// </summary>
|
|
internal static uint DecodeBcd(uint raw, int nibbles)
|
|
{
|
|
uint result = 0;
|
|
for (var i = nibbles - 1; i >= 0; i--)
|
|
{
|
|
var digit = (raw >> (i * 4)) & 0xF;
|
|
if (digit > 9)
|
|
throw new InvalidDataException(
|
|
$"Non-BCD nibble 0x{digit:X} at position {i} of raw=0x{raw:X}");
|
|
result = result * 10 + digit;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Encode a decimal value as N-nibble BCD. Caller is responsible for range-checking
|
|
/// against the nibble capacity (10^nibbles - 1).
|
|
/// </summary>
|
|
internal static uint EncodeBcd(uint value, int nibbles)
|
|
{
|
|
uint result = 0;
|
|
for (var i = 0; i < nibbles; i++)
|
|
{
|
|
var digit = value % 10;
|
|
result |= digit << (i * 4);
|
|
value /= 10;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private IModbusTransport RequireTransport() =>
|
|
_transport ?? throw new InvalidOperationException("ModbusDriver not initialized");
|
|
|
|
private const uint StatusBadInternalError = 0x80020000u;
|
|
private const uint StatusBadNodeIdUnknown = 0x80340000u;
|
|
private const uint StatusBadNotWritable = 0x803B0000u;
|
|
private const uint StatusBadOutOfRange = 0x803C0000u;
|
|
private const uint StatusBadNotSupported = 0x803D0000u;
|
|
private const uint StatusBadDeviceFailure = 0x808B0000u;
|
|
private const uint StatusBadCommunicationError = 0x80050000u;
|
|
|
|
/// <summary>
|
|
/// Map a server-returned Modbus exception code to the most informative OPC UA
|
|
/// StatusCode. Keeps the driver's outward-facing status surface aligned with what a
|
|
/// Modbus engineer would expect when reading the spec: exception 02 (Illegal Data
|
|
/// Address) surfaces as BadOutOfRange so clients can distinguish "tag wrong" from
|
|
/// generic BadInternalError, exception 04 (Server Failure) as BadDeviceFailure so
|
|
/// operators see a CPU-mode problem rather than a driver bug, etc. Per
|
|
/// <c>docs/v2/dl205.md</c>, DL205/DL260 returns only codes 01-04 — no proprietary
|
|
/// extensions.
|
|
/// </summary>
|
|
internal static uint MapModbusExceptionToStatus(byte exceptionCode) => exceptionCode switch
|
|
{
|
|
0x01 => StatusBadNotSupported, // Illegal Function — FC not in supported list
|
|
0x02 => StatusBadOutOfRange, // Illegal Data Address — register outside mapped range
|
|
0x03 => StatusBadOutOfRange, // Illegal Data Value — quantity over per-FC cap
|
|
0x04 => StatusBadDeviceFailure, // Server Failure — CPU in PROGRAM mode during protected write
|
|
0x05 or 0x06 => StatusBadDeviceFailure, // Acknowledge / Server Busy — long-running op / busy
|
|
0x0A or 0x0B => StatusBadCommunicationError, // Gateway path unavailable / target failed to respond
|
|
_ => StatusBadInternalError,
|
|
};
|
|
|
|
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
|
|
|
/// <summary>
|
|
/// Driver.Modbus-004: DisposeAsync must perform the same teardown as ShutdownAsync so
|
|
/// callers that use <c>await using</c> (without an explicit <c>ShutdownAsync</c>) do not
|
|
/// leak the probe loop, re-probe loop, and poll-engine background tasks. Shares
|
|
/// <see cref="TeardownAsync"/> with <see cref="ShutdownAsync"/> to keep them in sync.
|
|
/// </summary>
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await TeardownAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shared teardown helper used by both <see cref="ShutdownAsync"/> and
|
|
/// <see cref="DisposeAsync"/>. Cancels both background loops, disposes the poll engine,
|
|
/// and disposes the transport. Idempotent — safe to call more than once.
|
|
/// </summary>
|
|
private async Task TeardownAsync()
|
|
{
|
|
try { _probeCts?.Cancel(); } catch { }
|
|
_probeCts?.Dispose();
|
|
_probeCts = null;
|
|
|
|
try { _reprobeCts?.Cancel(); } catch { }
|
|
_reprobeCts?.Dispose();
|
|
_reprobeCts = null;
|
|
|
|
_tagsByName.Clear();
|
|
_lastPublishedByRef.Clear();
|
|
lock (_lastWrittenLock) _lastWrittenByRef.Clear();
|
|
lock (_autoProhibitedLock) _autoProhibited.Clear();
|
|
|
|
await _poll.DisposeAsync().ConfigureAwait(false);
|
|
|
|
if (_transport is not null) await _transport.DisposeAsync().ConfigureAwait(false);
|
|
_transport = null;
|
|
}
|
|
}
|