diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs
index 9d6b5479..4f456585 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs
@@ -89,10 +89,13 @@ public sealed class ModbusDriverOptions
public bool UseFC16ForSingleRegisterWrites { get; init; } = false;
///
- /// Reserved / no-op kill-switch for FC23 (Read/Write Multiple Registers). The
- /// driver does not currently emit FC23 — toggling this option has no observable effect
- /// today. The slot exists so a future block-read-coalescing enhancement that opts into
- /// FC23 can be disabled per-deployment without a code change. Default false.
+ /// Kill-switch for FC23 (Read/Write Multiple Registers). By default a multi-register holding
+ /// write (2–121 registers: 32/64-bit values, arrays, strings) is sent as ONE FC23 that writes
+ /// the block and reads the same block back, so the device confirms what it now holds. A unit
+ /// that answers FC23 with exception 01 (MELSEC QJ71MT91, S7 Modbus servers) is moved to FC16
+ /// automatically, for that write and every later one; one that fails FC23 at the transport is
+ /// moved too (that one write is reported as a comms failure). Set true to always use
+ /// FC16. Default false.
///
public bool DisableFC23 { get; init; } = false;
@@ -128,10 +131,11 @@ public sealed class ModbusDriverOptions
public bool DisableReadCoalescing { get; init; } = false;
///
- /// PLC family hint that drives the parser's family-native branch. When set to a
- /// non-Generic value, address strings using that family's native syntax (DL205 V2000 /
- /// MELSEC D100) parse to the right region + offset directly. Defaults to
- /// = Modicon-only behaviour preserved by default.
+ /// PLC family hint that drives the parser's family-native branch. A tag authored with an
+ /// addressString is resolved with this family at every initialize, so with a non-Generic
+ /// value that family's native syntax (DL205 V2000 octal / MELSEC D100) parses to the right
+ /// region + offset. Defaults to (Modicon / mnemonic grammar).
+ /// Tags authored by region + numeric address are unaffected.
///
public ModbusFamily Family { get; init; } = ModbusFamily.Generic;
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusTagDefinitionFactory.cs
index 86b0a4e9..d34c55ba 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusTagDefinitionFactory.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusTagDefinitionFactory.cs
@@ -32,6 +32,24 @@ public static class ModbusTagDefinitionFactory
/// The mapped definition when this returns .
/// when is a valid Modbus address object.
public static bool FromTagConfig(string tagConfig, string rawPath, out ModbusTagDefinition def)
+ => FromTagConfig(tagConfig, rawPath, ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out def);
+
+ ///
+ /// Family-aware overload: an addressString key (the Modicon / mnemonic / family-native grammar
+ /// of — 40001:F:CDAB, DL205 V2000, MELSEC D100)
+ /// is resolved with the DRIVER's / and is
+ /// authoritative for region, offset, type, bit, string length, byte order and array count; the
+ /// editor also persists the resolved region / address keys beside it for display and for
+ /// readers that predate it. Without an addressString this is exactly the 3-argument overload.
+ ///
+ /// The authored equipment-tag TagConfig JSON.
+ /// The tag's RawPath — becomes the definition's identity (Name).
+ /// The driver's PLC family (from its DriverConfig).
+ /// The driver's MELSEC sub-family; consulted only for MELSEC.
+ /// The mapped definition when this returns .
+ /// when is a valid Modbus address object.
+ public static bool FromTagConfig(string tagConfig, string rawPath, ModbusFamily family, MelsecFamily melsecSubFamily,
+ out ModbusTagDefinition def)
{
def = null!;
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
@@ -39,28 +57,60 @@ public static class ModbusTagDefinitionFactory
{
using var doc = JsonDocument.Parse(tagConfig);
var root = doc.RootElement;
- if (root.ValueKind != JsonValueKind.Object
- || !root.TryGetProperty("region", out _)
- || !root.TryGetProperty("address", out var addr))
- return false;
- if (addr.ValueKind != JsonValueKind.Number
- || !addr.TryGetInt32(out var address)
- || address < 0 || address > ushort.MaxValue)
- return false;
- // Strict enum reads: a present-but-invalid (typo'd) enum rejects the tag
- // (→ BadNodeIdUnknown) instead of silently defaulting to a wrong-width Good.
- if (!TagConfigJson.TryReadEnumStrict(root, "region", ModbusRegion.HoldingRegisters, out var region)) return false;
- // An absent dataType defaults per region: a bit region (Coils / DiscreteInputs) only carries
- // Bool, so defaulting it to Int16 would reject every bit tag authored without the key.
- if (!TagConfigJson.TryReadEnumStrict(root, "dataType", DefaultDataType(region), out var dataType)) return false;
+ if (root.ValueKind != JsonValueKind.Object) return false;
+
+ ModbusRegion region;
+ int address;
+ ModbusDataType dataType;
+ ModbusByteOrder byteOrder;
+ byte bitIndex;
+ ushort stringLength;
+ int? arrayCount;
+ var addressText = ReadAddressString(root, out var addressStringInvalid);
+ if (addressStringInvalid) return false;
+ if (addressText is not null)
+ {
+ if (!ModbusAddressParser.TryParse(addressText, family, melsecSubFamily, out var parsed, out _) || parsed is null)
+ return false;
+ region = parsed.Region;
+ address = parsed.Offset;
+ dataType = parsed.DataType;
+ byteOrder = parsed.ByteOrder;
+ bitIndex = parsed.Bit ?? 0;
+ stringLength = parsed.StringLength;
+ arrayCount = parsed.ArrayCount;
+ }
+ else
+ {
+ if (!root.TryGetProperty("region", out _) || !root.TryGetProperty("address", out var addr))
+ return false;
+ if (addr.ValueKind != JsonValueKind.Number
+ || !addr.TryGetInt32(out address)
+ || address < 0 || address > ushort.MaxValue)
+ return false;
+ // Strict enum reads: a present-but-invalid (typo'd) enum rejects the tag
+ // (→ BadNodeIdUnknown) instead of silently defaulting to a wrong-width Good.
+ if (!TagConfigJson.TryReadEnumStrict(root, "region", ModbusRegion.HoldingRegisters, out region)) return false;
+ // An absent dataType defaults per region: a bit region (Coils / DiscreteInputs) only carries
+ // Bool, so defaulting it to Int16 would reject every bit tag authored without the key.
+ if (!TagConfigJson.TryReadEnumStrict(root, "dataType", DefaultDataType(region), out dataType)) return false;
+ if (!TagConfigJson.TryReadEnumStrict(root, "byteOrder", ModbusByteOrder.BigEndian, out byteOrder)) return false;
+ bitIndex = (byte)ReadInt(root, "bitIndex");
+ stringLength = (ushort)ReadInt(root, "stringLength");
+ // isArray / arrayLength — optional keys authored by the typed Modbus tag editor.
+ // Canonical rule: a tag is an array iff isArray:true AND arrayLength >= 1.
+ // isArray:false (with any arrayLength) is scalar — the foundation materialises a
+ // scalar OPC UA node, so returning an array value would cause a shape mismatch.
+ // A 1-element array (isArray:true, arrayLength:1) IS valid and reads as short[1].
+ var isArray = ReadBool(root, "isArray");
+ var arrayLength = ReadInt(root, "arrayLength");
+ arrayCount = (isArray && arrayLength >= 1) ? arrayLength : null;
+ }
// Region/type compatibility: a bit region carries only Bool, a register region never Bool.
// A mismatch used to throw out of the coalescing planner (killing every tag's poll) or
// silently return a bool into an Int32 node.
if (!IsRegionTypeCompatible(region, dataType)) return false;
- if (!TagConfigJson.TryReadEnumStrict(root, "byteOrder", ModbusByteOrder.BigEndian, out var byteOrder)) return false;
if (!TagConfigJson.TryReadEnumStrict(root, "stringByteOrder", ModbusStringByteOrder.HighByteFirst, out var stringByteOrder)) return false;
- var bitIndex = (byte)ReadInt(root, "bitIndex");
- var stringLength = (ushort)ReadInt(root, "stringLength");
// Guard: String tags require StringLength >= 1. RegisterCount = (StringLength+1)/2,
// so StringLength=0 → 0 registers → spec-illegal FC03/FC04 with quantity=0 → PLC
// returns exception 03. Reject here so the driver surfaces BadNodeIdUnknown rather
@@ -69,14 +119,6 @@ public static class ModbusTagDefinitionFactory
// Guard: BitInRegister tags require BitIndex 0–15. Shift by ≥ 16 on an int overflows
// a ushort mask silently — reads always return false, writes have no effect.
if (dataType == ModbusDataType.BitInRegister && bitIndex > 15) return false;
- // isArray / arrayLength — optional keys authored by the typed Modbus tag editor.
- // Canonical rule: a tag is an array iff isArray:true AND arrayLength >= 1.
- // isArray:false (with any arrayLength) is scalar — the foundation materialises a
- // scalar OPC UA node, so returning an array value would cause a shape mismatch.
- // A 1-element array (isArray:true, arrayLength:1) IS valid and reads as short[1].
- var isArray = ReadBool(root, "isArray");
- var arrayLength = ReadInt(root, "arrayLength");
- int? arrayCount = (isArray && arrayLength >= 1) ? arrayLength : null;
// "writable" defaults to true when absent (today's value) — makes read-only equipment tags
// authorable. Node-level authz remains the effective write gate.
var writable = TagConfigJson.ReadWritable(root);
@@ -100,6 +142,42 @@ public static class ModbusTagDefinitionFactory
catch (InvalidOperationException) { return false; }
}
+ ///
+ /// Family-unaware acceptance, for the gates that do not know the owning driver's family (the deploy
+ /// gate's , CSV import, the AdminUI parse consult): accepts a blob SOME family
+ /// resolves, so the gate is never stricter than the driver it guards. Blobs without an
+ /// addressString behave exactly like .
+ ///
+ /// The authored equipment-tag TagConfig JSON.
+ /// The tag's RawPath.
+ /// The definition under the first family that resolves it.
+ /// when any family's driver would serve the tag.
+ public static bool FromTagConfigAnyFamily(string tagConfig, string rawPath, out ModbusTagDefinition def)
+ {
+ foreach (var (family, sub) in AllFamilies)
+ if (FromTagConfig(tagConfig, rawPath, family, sub, out def)) return true;
+ def = null!;
+ return false;
+ }
+
+ private static readonly (ModbusFamily Family, MelsecFamily Sub)[] AllFamilies =
+ [
+ (ModbusFamily.Generic, MelsecFamily.Q_L_iQR),
+ (ModbusFamily.DL205, MelsecFamily.Q_L_iQR),
+ (ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR),
+ (ModbusFamily.MELSEC, MelsecFamily.F_iQF),
+ ];
+
+ /// Reads the optional addressString: null when absent or blank; flags a non-string value.
+ private static string? ReadAddressString(JsonElement root, out bool invalid)
+ {
+ invalid = false;
+ if (!root.TryGetProperty("addressString", out var e) || e.ValueKind == JsonValueKind.Null) return null;
+ if (e.ValueKind != JsonValueKind.String) { invalid = true; return null; }
+ var text = e.GetString();
+ return string.IsNullOrWhiteSpace(text) ? null : text.Trim();
+ }
+
/// The data type an authored tag gets when its dataType key is absent.
/// The tag's region.
/// Bool for Coils / DiscreteInputs, Int16 for the register regions.
@@ -194,7 +272,7 @@ public static class ModbusTagDefinitionFactory
// The accept gate: whatever the driver reads draws nothing. See the remarks. The RAW reference is
// handed to the mapper — the driver reads the stored blob, not a trimmed copy of it.
- if (FromTagConfig(reference, InspectionRawPath, out _)) return warnings;
+ if (FromTagConfigAnyFamily(reference, InspectionRawPath, out _)) return warnings;
try
{
@@ -235,6 +313,24 @@ public static class ModbusTagDefinitionFactory
/// The list to append warnings to.
private static void DescribeRejection(JsonElement root, List warnings)
{
+ var addressText = ReadAddressString(root, out var addressStringInvalid);
+ if (addressStringInvalid)
+ {
+ warnings.Add("Modbus TagConfig 'addressString' is not a string — the tag will not resolve (BadNodeIdUnknown).");
+ return;
+ }
+ if (addressText is not null)
+ {
+ // The addressString is authoritative; the other address keys are not read at all.
+ if (!ModbusAddressParser.TryParse(addressText, out _, out var error))
+ warnings.Add($"Modbus 'addressString' '{addressText}' does not parse under any PLC family ({error}) — "
+ + "the tag will not resolve (BadNodeIdUnknown).");
+ else
+ warnings.Add($"Modbus 'addressString' '{addressText}' resolves to an unusable tag (check its type against "
+ + "the region) — the tag will not resolve (BadNodeIdUnknown).");
+ return;
+ }
+
if (!root.TryGetProperty("region", out _))
{
warnings.Add(
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
index 4aa312d3..2bc8e8ef 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
@@ -533,7 +533,7 @@ public sealed class ModbusDriver
_skippedTags.BeginRebuild(_options.RawTags.Count);
foreach (var entry in _options.RawTags)
{
- if (ModbusTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
+ if (ModbusTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, _options.Family, _options.MelsecSubFamily, out var def))
table[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent };
else
{
@@ -556,6 +556,7 @@ public sealed class ModbusDriver
_transport = transport;
Volatile.Write(ref _tagsByRawPath, table);
Volatile.Write(ref _refusalWarned, new ConcurrentDictionary(StringComparer.Ordinal));
+ Volatile.Write(ref _fc23Unsupported, new ConcurrentDictionary());
WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
// PR 23: kick off the probe loop once the transport is up. Initial state stays
@@ -1619,6 +1620,7 @@ public sealed class ModbusDriver
throw new ModbusWriteRefusedException(StatusBadOutOfRange,
$"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.");
+ if (await TryWriteViaFc23Async(transport, tag, bytes, qty, ct).ConfigureAwait(false)) return;
var pdu = new byte[6 + 1 + bytes.Length];
pdu[0] = 0x10;
pdu[1] = (byte)(tag.Address >> 8); pdu[2] = (byte)(tag.Address & 0xFF);
@@ -1634,6 +1636,79 @@ public sealed class ModbusDriver
}
}
+ /// FC23's spec ceiling on the write quantity (0x79); larger multi-register writes use FC16.
+ private const ushort Fc23MaxWriteRegisters = 121;
+
+ ///
+ /// Units that refused FC23 (exception 01) or failed it at the transport; their multi-register writes
+ /// use FC16 for the rest of this driver generation. Replaced (never cleared in place) on re-init.
+ ///
+ private ConcurrentDictionary _fc23Unsupported = new();
+
+ ///
+ /// Multi-register write via FC23 (Read/Write Multiple Registers): the device writes the block and
+ /// returns the SAME block read back in one transaction, so the write is confirmed by the device's own
+ /// post-write registers. Gated by . A device that does
+ /// not implement FC23 (MELSEC QJ71MT91, S7 Modbus servers — exception 01) falls back to FC16 for this
+ /// write and every later one on that unit, which is safe because an Illegal Function wrote nothing.
+ /// A transport failure on FC23 is reported (its outcome is unknown, so it is not retried as FC16) and
+ /// also moves the unit to FC16, so a gateway that cannot frame FC23 costs one failed write, not all.
+ /// A read-back that differs from what was written (PLC logic overwrote it in the same scan, or the
+ /// device clamped it) is logged once per tag; the write itself was accepted.
+ ///
+ /// when FC23 performed the write; to use FC16.
+ private async Task TryWriteViaFc23Async(
+ IModbusTransport transport, ModbusTagDefinition tag, byte[] bytes, ushort qty, CancellationToken ct)
+ {
+ var unit = ResolveUnitId(tag);
+ var unsupported = Volatile.Read(ref _fc23Unsupported);
+ // Only genuinely multi-register writes: a single register forced onto FC16 (UseFC16ForSingleRegisterWrites)
+ // stays on the function code the operator named.
+ if (_options.DisableFC23 || qty < 2 || qty > Fc23MaxWriteRegisters || unsupported.ContainsKey(unit)) return false;
+
+ var pdu = new byte[10 + 1 + bytes.Length];
+ pdu[0] = 0x17;
+ pdu[1] = (byte)(tag.Address >> 8); pdu[2] = (byte)(tag.Address & 0xFF); // read start
+ pdu[3] = (byte)(qty >> 8); pdu[4] = (byte)(qty & 0xFF); // read quantity
+ pdu[5] = (byte)(tag.Address >> 8); pdu[6] = (byte)(tag.Address & 0xFF); // write start
+ pdu[7] = (byte)(qty >> 8); pdu[8] = (byte)(qty & 0xFF); // write quantity
+ pdu[9] = (byte)bytes.Length;
+ Buffer.BlockCopy(bytes, 0, pdu, 10, bytes.Length);
+
+ byte[] resp;
+ try
+ {
+ resp = await transport.SendAsync(unit, pdu, ct).ConfigureAwait(false);
+ }
+ catch (ModbusException mex) when (mex.ExceptionCode == 0x01)
+ {
+ if (unsupported.TryAdd(unit, 0))
+ _logger.LogInformation(
+ "Modbus unit {Unit} does not implement FC23 (Illegal Function); multi-register writes use FC16. Driver={DriverInstanceId}",
+ unit, _driverInstanceId);
+ return false;
+ }
+ catch (Exception ex) when (ex is not ModbusException && !(ex is OperationCanceledException && ct.IsCancellationRequested))
+ {
+ if (unsupported.TryAdd(unit, 0))
+ _logger.LogWarning(ex,
+ "Modbus FC23 write to unit {Unit} failed at the transport; later multi-register writes to it use FC16. Driver={DriverInstanceId}",
+ unit, _driverInstanceId);
+ throw;
+ }
+
+ // resp = [0x17][byteCount][read-back registers…]
+ if (resp.Length < 2 || resp[1] != bytes.Length || resp.Length < 2 + resp[1])
+ throw new InvalidDataException(
+ $"Modbus FC23 response for {tag.Name} malformed: expected {bytes.Length} read-back bytes, got {(resp.Length < 2 ? -1 : resp[1])}");
+ if (!resp.AsSpan(2, bytes.Length).SequenceEqual(bytes)
+ && Volatile.Read(ref _refusalWarned).TryAdd("fc23:" + tag.Name, 0))
+ _logger.LogWarning(
+ "Modbus FC23 read-back of {RawPath} differs from the value written (device logic or clamping changed it); the write was accepted. Driver={DriverInstanceId}",
+ tag.Name, _driverInstanceId);
+ return true;
+ }
+
///
/// 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.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs
index 9aa8e0d7..0886124a 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs
@@ -78,7 +78,7 @@ public static class ModbusDriverFactoryExtensions
Host = dto.Host!,
Port = dto.Port ?? 502,
UnitId = dto.UnitId ?? 1,
- Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
+ Timeout = Span(dto.TimeoutMs, dto.Timeout) ?? TimeSpan.FromSeconds(2),
MaxRegistersPerRead = dto.MaxRegistersPerRead ?? 125,
MaxRegistersPerWrite = dto.MaxRegistersPerWrite ?? 123,
MaxCoilsPerRead = dto.MaxCoilsPerRead ?? 2000,
@@ -94,7 +94,7 @@ public static class ModbusDriverFactoryExtensions
: ParseEnum(dto.MelsecSubFamily, "", driverInstanceId, "MelsecSubFamily"),
Transport = dto.Transport is null ? ModbusTransportMode.Tcp
: ParseEnum(dto.Transport, "", driverInstanceId, "Transport"),
- AutoProhibitReprobeInterval = dto.AutoProhibitReprobeMs is { } reprobeMs ? TimeSpan.FromMilliseconds(reprobeMs) : null,
+ AutoProhibitReprobeInterval = Span(dto.AutoProhibitReprobeMs, dto.AutoProhibitReprobeInterval),
AutoReconnect = dto.AutoReconnect ?? true,
// v3: the deploy artifact (Wave C) populates rawTags with the cluster's authored raw Modbus
// tags. The driver maps each RawTagEntry's TagConfig blob through ModbusTagDefinitionFactory
@@ -104,22 +104,22 @@ public static class ModbusDriverFactoryExtensions
Probe = new ModbusProbeOptions
{
Enabled = dto.Probe?.Enabled ?? true,
- Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
- Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
+ Interval = Span(dto.Probe?.IntervalMs, dto.Probe?.Interval) ?? TimeSpan.FromSeconds(5),
+ Timeout = Span(dto.Probe?.TimeoutMs, dto.Probe?.Timeout) ?? TimeSpan.FromSeconds(2),
ProbeAddress = dto.Probe?.ProbeAddress ?? 0,
},
KeepAlive = dto.KeepAlive is null ? new ModbusKeepAliveOptions() : new ModbusKeepAliveOptions
{
Enabled = dto.KeepAlive.Enabled ?? true,
- Time = TimeSpan.FromMilliseconds(dto.KeepAlive.TimeMs ?? 30_000),
- Interval = TimeSpan.FromMilliseconds(dto.KeepAlive.IntervalMs ?? 10_000),
+ Time = Span(dto.KeepAlive.TimeMs, dto.KeepAlive.Time) ?? TimeSpan.FromSeconds(30),
+ Interval = Span(dto.KeepAlive.IntervalMs, dto.KeepAlive.Interval) ?? TimeSpan.FromSeconds(10),
RetryCount = dto.KeepAlive.RetryCount ?? 3,
},
- IdleDisconnectTimeout = dto.IdleDisconnectMs is { } ms ? TimeSpan.FromMilliseconds(ms) : null,
+ IdleDisconnectTimeout = Span(dto.IdleDisconnectMs, dto.IdleDisconnectTimeout),
Reconnect = dto.Reconnect is null ? new ModbusReconnectOptions() : new ModbusReconnectOptions
{
- InitialDelay = TimeSpan.FromMilliseconds(dto.Reconnect.InitialDelayMs ?? 0),
- MaxDelay = TimeSpan.FromMilliseconds(dto.Reconnect.MaxDelayMs ?? 30_000),
+ InitialDelay = Span(dto.Reconnect.InitialDelayMs, dto.Reconnect.InitialDelay) ?? TimeSpan.Zero,
+ MaxDelay = Span(dto.Reconnect.MaxDelayMs, dto.Reconnect.MaxDelay) ?? TimeSpan.FromSeconds(30),
BackoffMultiplier = dto.Reconnect.BackoffMultiplier ?? 2.0,
},
};
@@ -127,6 +127,23 @@ public static class ModbusDriverFactoryExtensions
return options;
}
+ ///
+ /// One timing knob, accepted in BOTH shapes the config can carry: the *Ms integer keys (CLI,
+ /// seed data, hand-authored JSON) and the TimeSpan-string keys the AdminUI driver form emits — it
+ /// serialises itself, so it writes "timeout":"00:00:07", not
+ /// timeoutMs. Before this every timing field on that form was silently ignored. The
+ /// millisecond key wins when both are present; an unparseable TimeSpan string is ignored (the
+ /// previous behaviour for these keys), never a reason to fail the driver.
+ ///
+ private static TimeSpan? Span(int? milliseconds, JsonElement? timeSpan)
+ {
+ if (milliseconds is { } ms) return TimeSpan.FromMilliseconds(ms);
+ if (timeSpan is { ValueKind: JsonValueKind.String } e
+ && TimeSpan.TryParse(e.GetString(), System.Globalization.CultureInfo.InvariantCulture, out var parsed))
+ return parsed;
+ return null;
+ }
+
private static T ParseEnum(string? raw, string? tagName, string driverInstanceId, string field) where T : struct, Enum
{
if (string.IsNullOrWhiteSpace(raw))
@@ -156,6 +173,8 @@ public static class ModbusDriverFactoryExtensions
public byte? UnitId { get; init; }
/// Gets or sets the timeout in milliseconds.
public int? TimeoutMs { get; init; }
+ /// Gets or sets the timeout as a TimeSpan string (the AdminUI form's shape).
+ public JsonElement? Timeout { get; init; }
/// Gets or sets the maximum number of registers per read operation.
public ushort? MaxRegistersPerRead { get; init; }
/// Gets or sets the maximum number of registers per write operation.
@@ -182,6 +201,8 @@ public static class ModbusDriverFactoryExtensions
public string? Transport { get; init; }
/// Gets or sets the automatic prohibition reprobei interval in milliseconds.
public int? AutoProhibitReprobeMs { get; init; }
+ /// Gets or sets the re-probe interval as a TimeSpan string (the AdminUI form's shape).
+ public JsonElement? AutoProhibitReprobeInterval { get; init; }
/// Gets or sets a value indicating whether automatic reconnection is enabled.
public bool? AutoReconnect { get; init; }
/// Gets or sets the authored raw tags (RawPath + TagConfig blob + WriteIdempotent) the
@@ -194,6 +215,8 @@ public static class ModbusDriverFactoryExtensions
public ModbusKeepAliveDto? KeepAlive { get; init; }
/// Gets or sets the idle disconnect timeout in milliseconds.
public int? IdleDisconnectMs { get; init; }
+ /// Gets or sets the idle disconnect timeout as a TimeSpan string (the AdminUI form's shape).
+ public JsonElement? IdleDisconnectTimeout { get; init; }
/// Gets or sets the reconnect configuration.
public ModbusReconnectDto? Reconnect { get; init; }
}
@@ -206,6 +229,10 @@ public static class ModbusDriverFactoryExtensions
public int? TimeMs { get; init; }
/// Gets or sets the keep-alive interval in milliseconds.
public int? IntervalMs { get; init; }
+ /// Gets or sets the keep-alive time as a TimeSpan string (the AdminUI form's shape).
+ public JsonElement? Time { get; init; }
+ /// Gets or sets the keep-alive interval as a TimeSpan string (the AdminUI form's shape).
+ public JsonElement? Interval { get; init; }
/// Gets or sets the retry count.
public int? RetryCount { get; init; }
}
@@ -216,6 +243,10 @@ public static class ModbusDriverFactoryExtensions
public int? InitialDelayMs { get; init; }
/// Gets or sets the maximum reconnect delay in milliseconds.
public int? MaxDelayMs { get; init; }
+ /// Gets or sets the initial reconnect delay as a TimeSpan string (the AdminUI form's shape).
+ public JsonElement? InitialDelay { get; init; }
+ /// Gets or sets the maximum reconnect delay as a TimeSpan string (the AdminUI form's shape).
+ public JsonElement? MaxDelay { get; init; }
/// Gets or sets the backoff multiplier for exponential reconnect delays.
public double? BackoffMultiplier { get; init; }
}
@@ -228,6 +259,10 @@ public static class ModbusDriverFactoryExtensions
public int? IntervalMs { get; init; }
/// Gets or sets the probe timeout in milliseconds.
public int? TimeoutMs { get; init; }
+ /// Gets or sets the probe interval as a TimeSpan string (the AdminUI form's shape).
+ public JsonElement? Interval { get; init; }
+ /// Gets or sets the probe timeout as a TimeSpan string (the AdminUI form's shape).
+ public JsonElement? Timeout { get; init; }
/// Gets or sets the probe register address.
public ushort? ProbeAddress { get; init; }
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusRtuFraming.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusRtuFraming.cs
index b7f20d05..9fd9c682 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusRtuFraming.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusRtuFraming.cs
@@ -93,9 +93,10 @@ public static class ModbusRtuFraming
// Exception frame: excCode(1) + CRC(2).
trailing = 1 + 2;
}
- else if (fc is 0x01 or 0x02 or 0x03 or 0x04)
+ else if (fc is 0x01 or 0x02 or 0x03 or 0x04 or 0x17)
{
- // Read response: byteCount(1) then byteCount data bytes + CRC(2).
+ // Read response (and FC23 Read/Write Multiple, whose reply is a read): byteCount(1) then
+ // byteCount data bytes + CRC(2).
var bc = new byte[1];
await ReadExactlyAsync(stream, bc, ct).ConfigureAwait(false);
var byteCount = bc[0];
@@ -105,9 +106,9 @@ public static class ModbusRtuFraming
}
else
{
- // Fixed 4-byte echo: correct for the write FCs this driver emits (05/06/0F/10). A future
- // variable-length response FC outside 01-04 (e.g. FC23) would be mis-sized here and
- // surface as a desync — revisit the FC-shape table if the driver starts emitting one.
+ // Fixed 4-byte echo: correct for the write FCs this driver emits (05/06/0F/10). FC23 is
+ // variable-length and handled above; any NEW variable-length response FC must be added there
+ // or it is mis-sized here and surfaces as a desync.
trailing = 4 + 2;
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/ModbusDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/ModbusDriverForm.razor
index a46a255e..ce1d77fc 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/ModbusDriverForm.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/ModbusDriverForm.razor
@@ -112,7 +112,7 @@