using System.Buffers.Binary; using System.Text.Json; 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, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable { /// /// #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. /// 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; } /// 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}"; // 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 string _driverInstanceId; public event EventHandler? OnDataChange; public event EventHandler? OnHostStatusChanged; // 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 CancellationTokenSource? _probeCts; private readonly ModbusDriverOptions _options; private readonly Func _transportFactory; private IModbusTransport? _transport; private DriverHealth _health = new(DriverState.Unknown, null, null); private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase); public ModbusDriver(ModbusDriverOptions options, string driverInstanceId, Func? transportFactory = null) { ArgumentNullException.ThrowIfNull(options); _options = options; _driverInstanceId = driverInstanceId; _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)); }); } // 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 Dictionary _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 _lastWrittenByRef = new(StringComparer.OrdinalIgnoreCase); private readonly object _lastWrittenLock = new(); 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) { _health = 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; _health = 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) { _health = 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) { try { _probeCts?.Cancel(); } catch { } _probeCts?.Dispose(); _probeCts = null; try { _reprobeCts?.Cancel(); } catch { } _reprobeCts?.Dispose(); _reprobeCts = null; // #151 — clear the prohibition set on shutdown so an explicit operator restart // (ReinitializeAsync) starts with a clean slate. The re-probe loop already retries // automatically when enabled; the restart path is the manual escape hatch. lock (_autoProhibitedLock) _autoProhibited.Clear(); await _poll.DisposeAsync().ConfigureAwait(false); if (_transport is not null) await _transport.DisposeAsync().ConfigureAwait(false); _transport = null; _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); } public DriverHealth GetHealth() => _health; 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> ReadAsync( IReadOnlyList 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(); 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); _health = 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); _health = new DriverHealth(DriverState.Degraded, _health.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); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); } } return results; } private async Task 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}"); } } /// /// Decode an FC01/FC02 coil-bitmap response into either a single bool (scalar tag) or a /// bool[] of elements (array tag). Modbus packs coils LSB-first /// within each byte, ascending address across bytes. /// private static object DecodeBitArray(ReadOnlySpan bitmap, int count, bool isArray) { 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; } /// /// Decode an array of register-backed values from a contiguous block. Each element /// occupies registers and is decoded with the same /// codec the scalar path uses, sliced from its position in the block. /// 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)"); } } /// Resolve the UnitId for a tag — per-tag override (#142) or driver-level fallback. private byte ResolveUnitId(ModbusTagDefinition tag) => tag.UnitId ?? _options.UnitId; /// /// #148 — runtime-discovered ranges where coalesced reads have failed (typically because /// the PLC has a write-only or protected register mid-block). Subsequent scans skip /// coalescing across these ranges and let the per-tag fallback handle the members. /// Cleared by ReinitializeAsync (operator restart) or by an explicit re-probe API /// (not yet shipped). /// /// /// #150 — 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; } private readonly Dictionary<(byte Unit, ModbusRegion Region, ushort Start, ushort End), ProhibitionState> _autoProhibited = new(); private readonly object _autoProhibitedLock = new(); private CancellationTokenSource? _reprobeCts; 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) { lock (_autoProhibitedLock) { // Multi-register prohibitions enter the bisection workflow on the next re-probe; // single-register prohibitions are already minimal and skip bisection. _autoProhibited[(unit, region, start, end)] = new ProhibitionState { LastProbedUtc = DateTime.UtcNow, SplitPending = end > start, }; } } /// Test/diagnostic accessor — returns the current auto-prohibited range count. internal int AutoProhibitedRangeCount { get { lock (_autoProhibitedLock) return _autoProhibited.Count; } } /// /// #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 ShutdownAsync. /// 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; } } } /// /// 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). /// 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); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch { lock (_autoProhibitedLock) if (_autoProhibited.TryGetValue(key, out var st)) st.LastProbedUtc = DateTime.UtcNow; } } /// /// #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). /// 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. } } private async Task 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; } } /// /// #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 and the WriteOnChangeOnly cache. Returns /// the set of indices the planner handled — the /// caller falls back to the per-tag path for the rest (arrays, coils, prohibited, unknown). /// private async Task> ReadCoalescedAsync( IModbusTransport transport, IReadOnlyList 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(); var handled = new HashSet(); // 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 block-level failure mark every member Bad — caller's // per-tag fallback won't re-try since handled-set already includes them; auto-split- // on-failure is a follow-up. 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); } _health = 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. Per-tag fallback reads each member individually // next time, so healthy tags around the protected hole keep working without // operator intervention. RecordAutoProhibition(group.Key.Unit, group.Key.Region, block.Start, block.End); var status = MapModbusExceptionToStatus(mex.ExceptionCode); foreach (var (idx, _) in block.Members) { // Don't mark members handled — leave them for the per-tag fallback in // the same scan so single-register reads can succeed for any non- // protected member. (Pre-#148 behaviour was to mark all Bad and skip.) // Members that ARE the protected register will fail again at single-tag // granularity and surface the per-tag exception code naturally. } _health = new DriverHealth(DriverState.Degraded, _health.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); } _health = new DriverHealth(DriverState.Degraded, _health.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 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...] var data = new byte[resp[1]]; Buffer.BlockCopy(resp, 2, data, 0, resp[1]); return data; } private async Task 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); var bitmap = new byte[resp[1]]; Buffer.BlockCopy(resp, 2, bitmap, 0, resp[1]); return bitmap; } /// /// Auto-chunk coil-array reads above MaxCoilsPerRead. Reassembles per-chunk bitmaps into /// one logical bitmap byte array sized for the full ; the /// downstream walks bits LSB-first the same way it would /// for a single-chunk response. /// private async Task 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 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> WriteAsync( IReadOnlyList 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. A // per-register lock 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 readonly System.Collections.Concurrent.ConcurrentDictionary _rmwLocks = new(); 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}"); } } /// /// 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. /// 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"), }; /// /// Read-modify-write one bit in a holding register. FC03 → bit-swap → FC06. Serialised /// against other bit writes targeting the same register via . /// 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 SubscribeAsync( IReadOnlyList 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 GetHostStatuses() { lock (_probeLock) return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)]; } /// /// Host identifier surfaced to IHostConnectivityProbe.GetHostStatuses and the Admin UI. /// Formatted as host:port so multiple Modbus drivers in the same server disambiguate /// by endpoint without needing the driver-instance-id in the Admin dashboard. /// 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 ---- /// /// 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). /// 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}"), }; /// /// 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): /// /// BigEndian (ABCD): bytes as-is — Modbus spec default. /// WordSwap (CDAB): swap word pairs (full register reversal across the value). /// ByteSwap (BADC): swap bytes within each register. /// FullReverse (DCBA): full byte reversal — equivalent to little-endian. /// /// private static byte[] NormalizeWordOrder(ReadOnlySpan 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 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}"); } } 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, ModbusDataType.Int64 or ModbusDataType.UInt64 => DriverDataType.Int32, // widening to Int32 loses precision; PR 25 adds Int64 to DriverDataType ModbusDataType.Float32 => DriverDataType.Float32, ModbusDataType.Float64 => DriverDataType.Float64, ModbusDataType.String => DriverDataType.String, ModbusDataType.Bcd16 or ModbusDataType.Bcd32 => DriverDataType.Int32, _ => DriverDataType.Int32, }; /// /// Decode an N-nibble binary-coded-decimal value. Each nibble of /// 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. /// 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; } /// /// Encode a decimal value as N-nibble BCD. Caller is responsible for range-checking /// against the nibble capacity (10^nibbles - 1). /// 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 = 0x80550000u; private const uint StatusBadCommunicationError = 0x80050000u; /// /// 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 /// docs/v2/dl205.md, DL205/DL260 returns only codes 01-04 — no proprietary /// extensions. /// 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(); public async ValueTask DisposeAsync() { if (_transport is not null) await _transport.DisposeAsync().ConfigureAwait(false); _transport = null; } }