Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
T
Joseph Doherty d32d89c340 fix(drivers): stop silently discarding driver config edits (§8.3, #516)
Five drivers ignored the config handed to them on reinitialize, serving the
options their constructor captured — and the deployment sealed green anyway.
Fixed on both halves, as chosen.

Seam (load-bearing): DriverSpawnPlanner routes a changed DriverConfig to
ToStop + ToSpawn instead of an in-place delta, making the factory the single
parse authority. This REVERSES the deliberate decision documented at
DriverSpawnPlan.cs:49-50 ("a pure DriverConfig change stays an in-place delta
— no reconnect"). That reasoning was correct about the resilience pipeline and
wrong about the driver. The accepted price is a reconnect per config edit.

Per-driver, and this split was not anticipated:
- Re-parse in place — Modbus, AbLegacy, OpcUaClient. ParseOptions extracted
  from each factory, called from InitializeAsync behind a HasConfigBody guard
  so "{}" still keeps the constructor options.
- Respawn-only, deliberately NOT re-parsed — Sql and FOCAS. Each builds more
  than options from config (Sql's dialect + resolved connection string,
  FOCAS's client-factory backend, both injected at construction), so adopting
  new options alone would run a NEW tag set against an OLD connection. Half a
  re-parse is worse than none. SqlDriver's doc-comment asserted the opposite
  premise — "config parsing belongs to the factory, which builds a fresh
  instance" — which was false when written and is true only now.

Two green seals removed from ApplyChildDelta: it overwrote the cached Spec
synchronously BEFORE the child dequeued the message, so the host believed the
new config was live and the next reconcile computed no delta, sealing the
drift permanently; and it Tell'd with no Receive<ApplyResult> registered, so a
failed reinit — including Galaxy's deliberate NotSupportedException —
dead-lettered.

Exposed (not created) by the change: a factory throw is a CONFIG error, and
SpawnChild catches it and silently substitutes a stub. Only a brand-new driver
could reach that before; an ordinary config edit can now. Raised Warning ->
Error with an actionable message. It still does not fail the deployment —
doing so would let one malformed driver block a fleet deploy, so that is a
follow-up rather than a drive-by.

Tests: every pre-existing reinit test in these suites passes "{}", the exact
input a guarded re-parser treats as "keep constructor options" — blind to this
defect by construction. New tests pass CHANGED json; the Modbus one was
verified falsifiable by deleting the re-parse (goes red). Two tests asserted
the old behaviour and were rewritten, including the positive control in
DriverHostActorUnreadableArtifactTests, whose observable moved from
UnsubscribeAsync to ShutdownAsync now that teardown happens by stopping the
child rather than emptying its desired set.

Remaining full-suite failures are pre-existing and environmental, verified
identical on the pre-change tree: Host.IntegrationTests (3) and
Driver.AbLegacy.IntegrationTests (4, docker fixture-gated).
2026-07-27 19:32:46 -04:00

1649 lines
79 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 (grouped at top for auditability) ----
/// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config on reinitialize
/// (#516). Only ever written on the init path, before any reader/transport uses it.</summary>
private 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;
// v3 authored-tag table, keyed by RawPath (the tag identity + driver wire reference). RawPath is
// case-sensitive ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper.
private readonly Dictionary<string, ModbusTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
// Resolves a read/write/subscribe RawPath reference to its tag definition via a single hit on the
// authored _tagsByRawPath table (v3: the reference is always a RawPath; a miss is a miss).
private readonly EquipmentTagRefResolver<ModbusTagDefinition> _resolver;
// Last-published value per tag, keyed by FullReference. Used by ShouldPublish to apply
// the deadband filter. Stored as object so all numeric types share one map; the comparison
// does a typed cast inside.
private readonly ConcurrentDictionary<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();
private readonly Dictionary<(byte Unit, ModbusRegion Region, ushort Start, ushort End), ProhibitionState> _autoProhibited = new();
private readonly object _autoProhibitedLock = new();
// Single-host probe state — Modbus driver talks to exactly one endpoint so the "hosts"
// collection has at most one entry. HostName is the Host:Port string so the Admin UI can
// display the PLC endpoint uniformly with Galaxy platforms/engines.
private readonly object _probeLock = new();
private HostState _hostState = HostState.Unknown;
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
private IModbusTransport? _transport;
private CancellationTokenSource? _probeCts;
private CancellationTokenSource? _reprobeCts;
private DriverHealth _health = new(DriverState.Unknown, null, null);
/// <summary>Occurs when a subscribed tag value changes.</summary>
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <summary>Occurs when host connectivity status changes.</summary>
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
// ---- nested types ----
/// <summary>
/// Per-prohibition state. <c>SplitPending</c> drives the re-probe loop's
/// bisection: when true and the range spans &gt; 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 ----
/// <summary>Initializes a new Modbus TCP driver with the specified options and transport factory.</summary>
/// <param name="options">Driver configuration options.</param>
/// <param name="driverInstanceId">Unique identifier for this driver instance.</param>
/// <param name="transportFactory">Factory to create the Modbus transport; defaults to
/// <see cref="ModbusTransportFactory.Create"/>, which honours <see cref="ModbusDriverOptions.Transport"/>.</param>
/// <param name="logger">Logger instance; defaults to null logger if not provided.</param>
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;
_resolver = new EquipmentTagRefResolver<ModbusTagDefinition>(
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
_transportFactory = transportFactory
?? (o => ModbusTransportFactory.Create(o));
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
{
if (!ShouldPublish(tagRef, snapshot)) return;
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot));
},
onError: HandlePollError,
backoffCap: PollBackoffCap);
}
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).</summary>
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
/// <summary>
/// 05/STAB-9 — routes a poll-loop reader failure to the driver health surface: logs it and
/// degrades to <see cref="DriverState.Degraded"/> preserving <c>LastSuccessfulRead</c>.
/// Never downgrades a <see cref="DriverState.Faulted"/> state (a stronger, config-level signal).
/// </summary>
/// <param name="ex">The exception caught by the poll engine.</param>
internal void HandlePollError(Exception ex)
{
_logger.LogWarning(ex, "Modbus poll reader failed. Driver={DriverInstanceId}", _driverInstanceId);
var current = ReadHealth();
if (current.State != DriverState.Faulted)
WriteHealth(new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message));
}
/// <summary>
/// Resolve a reference to the per-slave host key (<c>host:port/unitN</c>) that keys its
/// per-host resilience (bulkhead / circuit breaker). CONV-4: routes through
/// <see cref="EquipmentTagRefResolver{TDef}"/> so an equipment-tag reference (raw TagConfig
/// JSON) keys its OWN authored unit — via the tag's optional <c>unitId</c> or the driver
/// default — rather than the single-slave fallback. The resolver caches parses, so this adds
/// no per-call cost after first use.
/// </summary>
/// <param name="fullReference">The tag reference (authored name or equipment-tag JSON).</param>
/// <returns>The per-slave host key.</returns>
public string ResolveHost(string fullReference)
{
if (_resolver.TryResolve(fullReference, out var tag))
return BuildSlaveHostName(ResolveUnitId(tag));
// Unknown reference — fall back to driver-instance host (single-slave behaviour).
return HostName;
}
/// <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 (!_resolver.TryResolve(tagRef, out var tag) || tag.Deadband is null) return true;
if (snapshot.Value is null) return true;
// Deadband only applies to numeric scalar types — array / Bool / String publishes
// unconditionally. Easier to special-case skip than to enumerate the supported types.
if (tag.ArrayCount.HasValue || tag.DataType is ModbusDataType.Bool or ModbusDataType.BitInRegister or ModbusDataType.String)
return true;
if (!_lastPublishedByRef.TryGetValue(tagRef, out var prev))
{
// First sample passes through unconditionally — the threshold can't be evaluated
// without a baseline. The publish lands and seeds the comparison.
_lastPublishedByRef[tagRef] = snapshot.Value;
return true;
}
var newD = Convert.ToDouble(snapshot.Value);
var oldD = Convert.ToDouble(prev);
if (Math.Abs(newD - oldD) < tag.Deadband.Value) return false;
_lastPublishedByRef[tagRef] = snapshot.Value;
return true;
}
/// <inheritdoc />
public string DriverInstanceId => _driverInstanceId;
/// <inheritdoc />
public string DriverType => "Modbus";
/// <summary>True when the supplied DriverConfig JSON carries a real body. The bootstrapper always
/// passes a populated document; some unit tests pass <c>"{}"</c> or an empty string to exercise
/// lifecycle shape without a config — those keep the constructor-supplied options.</summary>
private static bool HasConfigBody(string? driverConfigJson)
{
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
var trimmed = driverConfigJson.Trim();
return trimmed is not "{}" and not "[]";
}
/// <inheritdoc />
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
try
{
// #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver
// contract is honoured. Without this the driver keeps serving the options it was CONSTRUCTED
// with, so an operator's edit is discarded while the deployment still seals green. An empty /
// placeholder document (the "{}" some unit tests pass) keeps the constructor-supplied options.
if (HasConfigBody(driverConfigJson))
_options = ModbusDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
_transport = _transportFactory(_options);
await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false);
// Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig
// blob is mapped by the pure factory; the entry's WriteIdempotent flag is threaded onto the
// def (it lives on the RawTagEntry, not inside the blob). A mapper miss is skipped (logged),
// never thrown — a bad tag must not fail the whole driver init.
foreach (var entry in _options.RawTags)
{
if (ModbusTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
_tagsByRawPath[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent };
else
_logger.LogWarning(
"Modbus tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
_driverInstanceId, entry.RawPath);
}
WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
// PR 23: kick off the probe loop once the transport is up. Initial state stays
// Unknown until the first probe tick succeeds — avoids broadcasting a premature
// Running transition before any register round-trip has happened.
if (_options.Probe.Enabled)
{
_probeCts = new CancellationTokenSource();
_ = Task.Run(() => ProbeLoopAsync(_probeCts.Token), _probeCts.Token);
}
if (_options.AutoProhibitReprobeInterval is not null)
{
_reprobeCts = new CancellationTokenSource();
_ = Task.Run(() => ReprobeLoopAsync(_reprobeCts.Token), _reprobeCts.Token);
}
}
catch (Exception ex)
{
WriteHealth(new DriverHealth(DriverState.Faulted, null, ex.Message));
throw;
}
}
/// <inheritdoc />
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await ShutdownAsync(cancellationToken);
await InitializeAsync(driverConfigJson, cancellationToken);
}
/// <inheritdoc />
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
var lastRead = ReadHealth().LastSuccessfulRead;
await TeardownAsync().ConfigureAwait(false);
WriteHealth(new DriverHealth(DriverState.Unknown, lastRead, null));
}
/// <inheritdoc />
public DriverHealth GetHealth() => ReadHealth();
/// <summary>
/// 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>
/// Barrier-protected publish of a new <c>_health</c> snapshot.
/// </summary>
private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value);
/// <inheritdoc />
public long GetMemoryFootprint() => 0;
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
// ---- ITagDiscovery ----
/// <inheritdoc />
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
/// <inheritdoc />
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
var folder = builder.Folder("Modbus", "Modbus");
// Surface the authored raw tags (mapped into the RawPath → def table at Initialize). The def's
// Name is the RawPath, used for both the browse name and the driver FullName.
foreach (var t in _tagsByRawPath.Values)
{
folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
FullName: t.Name,
DriverDataType: MapDataType(t.DataType),
IsArray: t.ArrayCount.HasValue,
ArrayDim: t.ArrayCount.HasValue ? (uint)t.ArrayCount.Value : null,
SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: t.WriteIdempotent));
}
return Task.CompletedTask;
}
// ---- IReadable ----
/// <inheritdoc />
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
var transport = RequireTransport();
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
var coalesced = _options.MaxReadGap > 0
? await ReadCoalescedAsync(transport, fullReferences, results, now, cancellationToken).ConfigureAwait(false)
: new HashSet<int>();
for (var i = 0; i < fullReferences.Count; i++)
{
if (coalesced.Contains(i)) continue;
if (!_resolver.TryResolve(fullReferences[i], out var tag))
{
results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
continue;
}
try
{
var value = await ReadOneAsync(transport, tag, cancellationToken).ConfigureAwait(false);
results[i] = new DataValueSnapshot(value, 0u, now, now);
WriteHealth(new DriverHealth(DriverState.Healthy, now, null));
// Invalidate the WriteOnChangeOnly cache when the read returns a different value
// — typically an HMI-side or PLC-internal change. Without this, a setpoint
// tweaked at the panel could be silently re-suppressed when our client tried
// to restore it.
if (_options.WriteOnChangeOnly)
{
lock (_lastWrittenLock)
{
if (_lastWrittenByRef.TryGetValue(fullReferences[i], out var prev) && !Equals(prev, value))
_lastWrittenByRef.Remove(fullReferences[i]);
}
}
}
catch (ModbusException mex)
{
results[i] = new DataValueSnapshot(null, MapModbusExceptionToStatus(mex.ExceptionCode), null, now);
WriteHealth(new DriverHealth(DriverState.Degraded, ReadHealth().LastSuccessfulRead, mex.Message));
}
catch (Exception ex)
{
// Non-Modbus-layer failure: socket dropped, timeout, malformed response. Surface
// as communication error so callers can distinguish it from tag-level faults.
results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
WriteHealth(new DriverHealth(DriverState.Degraded, ReadHealth().LastSuccessfulRead, ex.Message));
}
}
return results;
}
private async Task<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)
{
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;
}
case ModbusDataType.String:
{
// Each element occupies elementRegs registers = elementBytes bytes, encoding
// StringLength chars packed high-byte-first or low-byte-first per tag.StringByteOrder.
// DecodeRegister handles the per-element slice identically to the scalar path.
var arr = new string[count];
for (var i = 0; i < count; i++)
arr[i] = (string)DecodeRegister(data.AsSpan(i * elementBytes, elementBytes), tag);
return arr;
}
case ModbusDataType.BitInRegister:
{
// Each element is 1 holding register (2 bytes). The same BitIndex is applied to
// every element. DecodeRegister returns bool for BitInRegister scalar slices.
var arr = new bool[count];
for (var i = 0; i < count; i++)
arr[i] = (bool)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 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,
};
}
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>
/// 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>
/// 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, narrowed by bisection, cleared by the
/// re-probe loop when ranges become healthy again.
/// </summary>
/// <returns>The current set of auto-prohibited register ranges.</returns>
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>
/// Periodic re-probe loop, augmented 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)
{
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>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
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>
/// 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.
}
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>
/// 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 (!_resolver.TryResolve(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;
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, 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). 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)
{
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.
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);
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 ----
/// <inheritdoc />
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 (!_resolver.TryResolve(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;
}
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 (InvalidDataException)
{
results[i] = new WriteResult(StatusBadCommunicationError);
}
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 readRaw = await ReadRegisterBlockAsync(transport, ResolveUnitId(tag), 0x03, tag.Address, 1, ct).ConfigureAwait(false);
// readRaw = [hi][lo] — ReadRegisterBlockAsync strips the fc+byte-count header and
// validates the payload length, so [0] and [1] are always safe to index here.
var current = (ushort)((readRaw[0] << 8) | readRaw[1]);
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) ----
/// <inheritdoc />
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
/// <inheritdoc />
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
_poll.Unsubscribe(handle);
return Task.CompletedTask;
}
// ---- IHostConnectivityProbe ----
/// <inheritdoc />
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>
/// <param name="tag">Tag definition to measure.</param>
/// <returns>The number of 16-bit registers the tag occupies.</returns>
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;
}
/// <summary>Decodes a register value according to the tag's data type.</summary>
/// <param name="data">Raw register bytes.</param>
/// <param name="tag">Tag definition specifying the data type.</param>
/// <returns>The decoded value, boxed as the CLR type matching the tag's data type.</returns>
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}");
}
}
/// <summary>Encodes a value into register bytes according to the tag's data type.</summary>
/// <param name="value">Value to encode.</param>
/// <param name="tag">Tag definition specifying the data type.</param>
/// <returns>The encoded register bytes.</returns>
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>
/// <param name="t">Modbus logical data type to map.</param>
/// <returns>The corresponding driver-agnostic data type.</returns>
internal 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 => DriverDataType.Int64,
ModbusDataType.UInt64 => DriverDataType.UInt64,
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 &gt; 9 —
/// the hardware sometimes produces garbage during transitions and silent non-BCD reads
/// would quietly corrupt the caller's data.
/// </summary>
/// <param name="raw">Raw BCD value.</param>
/// <param name="nibbles">Number of nibbles to decode.</param>
/// <returns>The decoded decimal value.</returns>
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>
/// <param name="value">Decimal value to encode.</param>
/// <param name="nibbles">Number of nibbles to encode.</param>
/// <returns>The encoded N-nibble BCD value.</returns>
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>
/// <param name="exceptionCode">Modbus exception code.</param>
/// <returns>The mapped OPC UA StatusCode value.</returns>
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,
};
/// <summary>Releases resources used by the driver.</summary>
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
/// <summary>
/// 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>
/// <returns>A task that represents the asynchronous operation.</returns>
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;
_tagsByRawPath.Clear();
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
_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;
}
}