diff --git a/docs/v2/modbus-addressing.md b/docs/v2/modbus-addressing.md
index 20950a38..74fbfe2c 100644
--- a/docs/v2/modbus-addressing.md
+++ b/docs/v2/modbus-addressing.md
@@ -149,7 +149,11 @@ Beyond per-tag addressing, `ModbusDriverOptions` exposes (#139–#143):
- `UseFC15ForSingleCoilWrites` — force FC15 (write multiple coils
qty=1) for single-coil writes. Safety/audit PLCs may require this.
- `UseFC16ForSingleRegisterWrites` — same for FC16 vs FC06.
-- `DisableFC23` — kill switch for FC23 (currently unused; reserved).
+- `UseFC23ForMultiRegisterWrites` (default `false`) — opt-in: a 2–121-register
+ holding write is sent as one FC23 (write + read-back). A unit answering
+ exception 01 moves to FC16 until the next re-init. Without the key the
+ wire stays FC16 (#741).
+- `DisableFC23` — hard kill switch: never send FC23, even when opted in.
### Subscribe (#141)
- Per-tag `Deadband` — suppress sub-threshold publishes on numeric tags.
@@ -165,9 +169,13 @@ Beyond per-tag addressing, `ModbusDriverOptions` exposes (#139–#143):
this tag in isolation regardless of `MaxReadGap`).
### Block-read coalescing (#143)
-- `MaxReadGap` (default 0 = off) — gap budget the planner is willing to
- bridge between adjacent register tags. With `MaxReadGap=10`, three tags
- at HR 100/102/110 collapse into one FC03 of quantity 11.
+- `MaxReadGap` — gap budget the planner is willing to bridge between
+ adjacent tags of one (unit, region). With `MaxReadGap=10`, three tags at
+ HR 100/102/110 collapse into one FC03 of quantity 11. An explicit `0` =
+ coalescing off (its historical meaning); the default `8` applies only when
+ the key is absent (#741). Coils / DiscreteInputs (gap in bits), `String`
+ scalars and same-word `BitInRegister` tags coalesce too.
+- `DisableReadCoalescing` — turn coalescing off regardless of the gap.
### Coalescing auto-recovery (#148 / #150 / #151 / #152)
- A coalesced read that fails with a Modbus exception (write-only or
@@ -231,3 +239,43 @@ the current Kepware "Modbus Ethernet Driver Help" PDF and Ignition's
"Modbus Addressing" user-manual page — if a critical tool's mnemonics
have shifted, add aliases in `ModbusAddressParser.TryParseType` rather
than asking users to rewrite spreadsheets.
+
+
+## Address strings, families and region/type rules (#741)
+
+- **One authority: `ModbusTagAddressResolution` (Driver.Modbus.Contracts).** The
+ driver's tag reader, the deploy gate (`Inspect`), the AdminUI editor model
+ (load / save / `Validate` / the hint text) and the CSV import/export all ask
+ it which part of a blob is the address; none restates the rule. Its
+ agreement is pinned by one table-driven test that runs every persisted shape
+ through every consumer (`ModbusAddressAuthorityAgreementTests`).
+- **Whenever a tag carries both `region` and `address`, those keys are its
+ address** — a classic tag, an editor-pinned tag, and a pre-#741 row that
+ carried an `addressString` beside its keys (the old driver never read the
+ string) all load from the keys, exactly as before, and a family change never
+ remaps them. Only a **string-only** blob (an `addressString` with NO
+ `region`/`address` — CSV / API authoring) is resolved through
+ `ModbusAddressParser` with the **driver's** `Family` / `MelsecSubFamily`; a
+ family that cannot parse it refuses the tag (BadNodeIdUnknown). It is never
+ served as HR[0].
+- `"addressResolved": true` is editor display / CSV state only: it never makes
+ keys authoritative, and a marker on a blob WITHOUT keys is ignored (the blob
+ is string-only) and dropped on the next editor save.
+- The AdminUI never writes default keys it did not resolve: the editor model
+ stamps `region`/`address`/type only when the loaded blob had the keys, or when
+ a field is set (hand edit, CSV address cell, Resolve). A string-only,
+ non-string-`addressString` or address-less blob keeps its keys unwritten, and
+ its CSV export leaves the address cells blank.
+- The editor's "Resolve with driver family" persists the resolved
+ `region`/`address`/type fields plus `"addressResolved": true` (the pinned
+ state). Editing a resolved field by hand in the editor drops the string and
+ the marker. A typed CSV address cell keeps them only while the string still
+ describes the cells under some family (so an unchanged export → import of a
+ pinned row is lossless); a contradicting cell drops them, and the import
+ review grid reports that column as an override.
+- Coils / DiscreteInputs carry only Bool. A bit-region tag persisted with a
+ register `dataType` (the pre-#741 editor saved `Int16`) is coerced to Bool,
+ never refused. A register region with `Bool` is refused (use
+ `BitInRegister`).
+- Exception 01/02/03 are device refusals: per-tag Bad, one Warning per tag,
+ no driver Degraded; reads and writes share one comms-fault health slot.
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ReadCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ReadCommand.cs
index a2378a55..3b48f4d1 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ReadCommand.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/ReadCommand.cs
@@ -57,6 +57,14 @@ public sealed class ReadCommand : ModbusCommandBase
ValidateEndpoint();
var ct = console.RegisterCancellationHandler();
+ // The driver's tag factory refuses an incompatible region/type pair (the tag would read
+ // BadNodeIdUnknown) — say why up front instead.
+ if (!ModbusTagDefinitionFactory.IsRegionTypeCompatible(Region, DataType))
+ throw new CliFx.Exceptions.CommandException(
+ Region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs
+ ? $"Region '{Region}' only carries Bool values (--type Bool); got '{DataType}'."
+ : $"Region '{Region}' carries register types; use --type BitInRegister for a bit of a register, not '{DataType}'.");
+
var tagName = SynthesiseTagName(Region, Address, DataType);
var tag = new ModbusTagDefinition(
Name: tagName,
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/WriteCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/WriteCommand.cs
index 07bb3bf7..e1876a49 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/WriteCommand.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/Commands/WriteCommand.cs
@@ -74,6 +74,9 @@ public sealed class WriteCommand : ModbusCommandBase
throw new CliFx.Exceptions.CommandException(
$"Region 'Coils' only supports boolean values (--type Bool). " +
$"Type '{DataType}' cannot represent a single-bit coil write.");
+ if (!ModbusTagDefinitionFactory.IsRegionTypeCompatible(Region, DataType))
+ throw new CliFx.Exceptions.CommandException(
+ $"Region '{Region}' carries register types; use --type BitInRegister for a bit of a register, not '{DataType}'.");
var tagName = ReadCommand.SynthesiseTagName(Region, Address, DataType);
var tag = new ModbusTagDefinition(
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 39a4104e..331aa28a 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,13 +89,24 @@ 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.
+ /// Hard kill-switch for FC23 (Read/Write Multiple Registers): when true the driver never
+ /// emits FC23, even with set. Default false.
+ /// FC23 is OPT-IN — a config without keeps the FC16 wire
+ /// behaviour, so the persisted "disableFC23": false every form-saved config carries changes
+ /// nothing on the wire.
///
public bool DisableFC23 { get; init; } = false;
+ ///
+ /// Opt-in: send a multi-register holding write (2–121 registers: 32/64-bit values, arrays, strings)
+ /// 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) moves
+ /// to FC16 for that write and every later one until the next initialize; one that fails FC23 at the
+ /// transport moves too (that one write is reported as a comms failure). Ignored when
+ /// is set. Default false = FC16, the historical behaviour.
+ ///
+ public bool UseFC23ForMultiRegisterWrites { get; init; } = false;
+
///
/// Interval for the background re-probe loop that retries auto-prohibited
/// coalesced ranges. When non-null, every AutoProhibitReprobeInterval
@@ -107,20 +118,32 @@ public sealed class ModbusDriverOptions
public TimeSpan? AutoProhibitReprobeInterval { get; init; } = null;
///
- /// Block-read coalescing budget. When non-zero, the read planner combines tags
- /// in the same (UnitId, Region) group whose addresses are at most this many registers
- /// apart into a single FC03/FC04/FC01/FC02 read. The sliced response is then dispatched
- /// back to per-tag values. Default 0 = no coalescing — every tag gets its own
- /// PDU (preserves the previous behaviour). Typical opt-in values are 5..32 — large enough
- /// to bridge a few unused registers, small enough to avoid trampling protected holes.
+ /// Block-read coalescing budget. When non-zero, the read planner combines tags in the same
+ /// (UnitId, Region) group whose addresses are at most this many registers (Coils / DiscreteInputs:
+ /// bits) apart into a single FC03/FC04/FC01/FC02 read, then slices the response back into per-tag
+ /// values (several BitInRegister tags on one status word fold into one read). 0 = no
+ /// coalescing — an explicitly authored 0 keeps that historical meaning. Default 8 applies
+ /// only when the key is ABSENT. A range the device refuses is auto-prohibited and read per tag.
+ /// also turns it off; coalesceProhibited exempts one tag.
///
- public ushort MaxReadGap { get; init; } = 0;
+ public ushort MaxReadGap { get; init; } = DefaultMaxReadGap;
+
+ /// The default (shared by the factory DTO and the AdminUI form).
+ public const ushort DefaultMaxReadGap = 8;
///
- /// 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.
+ /// When true, every tag is read with its own PDU (no block coalescing at all) — for a device
+ /// that mis-answers merged reads in a way the auto-prohibition cannot catch. Default false.
+ ///
+ public bool DisableReadCoalescing { get; init; } = false;
+
+ ///
+ /// PLC family hint that drives the parser's family-native branch. A STRING-ONLY tag (an
+ /// addressString with no region/address keys — see ) 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/ModbusTagAddressResolution.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusTagAddressResolution.cs
new file mode 100644
index 00000000..a18dc4b4
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusTagAddressResolution.cs
@@ -0,0 +1,251 @@
+using System.Text.Json;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
+
+///
+/// Which part of a Modbus TagConfig blob names the tag's address — the output of
+/// .
+///
+public enum ModbusAddressShape
+{
+ ///
+ /// Both region and address are present: those keys are the address, whatever
+ /// addressString / addressResolved sit beside them (classic, editor-pinned, or a pre-#741 row
+ /// whose string the old driver never read).
+ ///
+ Keys,
+
+ ///
+ /// The keys are not both present and addressString is a non-blank string: the string is the address,
+ /// resolved with the DRIVER's family. A family that cannot parse it refuses the tag.
+ ///
+ StringOnly,
+
+ /// The keys are not both present and addressString is present but not a string: refused.
+ InvalidAddressString,
+
+ /// Neither the keys nor a usable addressString: refused (the tag names no address).
+ NoAddress,
+}
+
+/// The address part of a Modbus tag, as the driver will poll it.
+/// Which part of the blob named it ( or ).
+/// The register region.
+/// The zero-based offset.
+/// The data type (already coerced to Bool for a bit region).
+/// The word/byte order.
+/// The bit index (BitInRegister).
+/// The string length (String).
+/// The element count, or null for a scalar.
+public sealed record ModbusResolvedAddress(
+ ModbusAddressShape Shape,
+ ModbusRegion Region,
+ ushort Address,
+ ModbusDataType DataType,
+ ModbusByteOrder ByteOrder,
+ byte BitIndex,
+ ushort StringLength,
+ int? ArrayCount);
+
+/// The outcome of .
+/// The blob's address shape (always set, also on a refusal).
+/// The resolved address, or null when the tag is refused.
+/// Why the tag is refused, or null when it resolved.
+public readonly record struct ModbusAddressResolution(ModbusAddressShape Shape, ModbusResolvedAddress? Address, string? Refusal)
+{
+ /// Whether the driver serves the tag at .
+ public bool IsResolved => Address is not null;
+}
+
+///
+/// The one authority for which address a Modbus TagConfig names (#741, review r3 N3). The driver's
+/// tag reader, the deploy gate, the AdminUI editor model (load / save / Validate / display) and the CSV
+/// import/export all ask this class; none of them restates the precedence.
+/// The rule:
+///
+/// When region AND address are both present they are the address
+/// (). No pre-#741 shape changes the address it resolves to: the old
+/// driver read the keys only and ignored any addressString.
+/// Otherwise a non-blank string addressString is the address
+/// (), parsed by with the
+/// DRIVER's family. If that family cannot parse it the tag is REFUSED — never served as HR[0].
+/// Anything else is refused.
+///
+/// addressResolved is editor display / CSV state only and never changes the shape.
+///
+public static class ModbusTagAddressResolution
+{
+ /// The TagConfig key carrying the parser-grammar address (40001:F:CDAB, V2000, D100).
+ public const string AddressStringKey = "addressString";
+
+ ///
+ /// The key the editor writes beside the keys it resolved from an addressString
+ /// ("addressResolved": true). Display / CSV state only: it never decides the address.
+ ///
+ public const string AddressResolvedKey = "addressResolved";
+
+ /// Every (family, MELSEC sub-family) a driver can be configured with — the family-unaware gates' search space.
+ public static IReadOnlyList<(ModbusFamily Family, MelsecFamily Sub)> AllFamilies { get; } =
+ [
+ (ModbusFamily.Generic, MelsecFamily.Q_L_iQR),
+ (ModbusFamily.DL205, MelsecFamily.Q_L_iQR),
+ (ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR),
+ (ModbusFamily.MELSEC, MelsecFamily.F_iQF),
+ ];
+
+ /// Classifies an object-rooted TagConfig by which part of it names the address.
+ /// The TagConfig root (a non-object is ).
+ /// The address shape.
+ public static ModbusAddressShape Classify(JsonElement root)
+ {
+ if (root.ValueKind != JsonValueKind.Object) return ModbusAddressShape.NoAddress;
+ if (root.TryGetProperty("region", out _) && root.TryGetProperty("address", out _)) return ModbusAddressShape.Keys;
+ if (!root.TryGetProperty(AddressStringKey, out var s) || s.ValueKind == JsonValueKind.Null) return ModbusAddressShape.NoAddress;
+ if (s.ValueKind != JsonValueKind.String) return ModbusAddressShape.InvalidAddressString;
+ return string.IsNullOrWhiteSpace(s.GetString()) ? ModbusAddressShape.NoAddress : ModbusAddressShape.StringOnly;
+ }
+
+ /// Classifies a TagConfig JSON string (blank / malformed / non-object ⇒ ).
+ /// The TagConfig JSON.
+ /// The address shape.
+ public static ModbusAddressShape Classify(string? tagConfig)
+ {
+ if (string.IsNullOrWhiteSpace(tagConfig)) return ModbusAddressShape.NoAddress;
+ try
+ {
+ using var doc = JsonDocument.Parse(tagConfig);
+ return Classify(doc.RootElement);
+ }
+ catch (JsonException) { return ModbusAddressShape.NoAddress; }
+ }
+
+ /// Resolves a TagConfig JSON string's address under one family.
+ /// The TagConfig JSON.
+ /// The driver's PLC family.
+ /// The driver's MELSEC sub-family.
+ /// The resolution.
+ public static ModbusAddressResolution Resolve(string? tagConfig, ModbusFamily family, MelsecFamily melsecSubFamily)
+ {
+ if (string.IsNullOrWhiteSpace(tagConfig))
+ return Refused(ModbusAddressShape.NoAddress, "Modbus TagConfig is blank — the tag names no address.");
+ try
+ {
+ using var doc = JsonDocument.Parse(tagConfig);
+ return Resolve(doc.RootElement, family, melsecSubFamily);
+ }
+ catch (JsonException)
+ {
+ return Refused(ModbusAddressShape.NoAddress, "Modbus TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
+ }
+ }
+
+ ///
+ /// Resolves the address of an object-rooted TagConfig under one family — the rule in the class remarks.
+ /// Key reads are STRICT (a present-but-invalid region / dataType / byteOrder, or an
+ /// address that is not an integer 0–65535, refuses the tag); a bit region's data type is coerced to
+ /// Bool (pre-#741 editor blobs persisted Int16 there and the old driver ignored it).
+ ///
+ /// The TagConfig root.
+ /// The driver's PLC family.
+ /// The driver's MELSEC sub-family.
+ /// The resolution.
+ public static ModbusAddressResolution Resolve(JsonElement root, ModbusFamily family, MelsecFamily melsecSubFamily)
+ {
+ var shape = Classify(root);
+ try
+ {
+ switch (shape)
+ {
+ case ModbusAddressShape.Keys:
+ return ResolveKeys(root);
+ case ModbusAddressShape.StringOnly:
+ {
+ var text = root.GetProperty(AddressStringKey).GetString()!.Trim();
+ if (!ModbusAddressParser.TryParse(text, family, melsecSubFamily, out var parsed, out var error) || parsed is null)
+ return Refused(shape, $"Modbus 'addressString' '{text}' does not parse under the {Describe(family, melsecSubFamily)} family ({error}) — the tag will not resolve (BadNodeIdUnknown).");
+ return Resolved(shape, parsed.Region, parsed.Offset, parsed.DataType, parsed.ByteOrder,
+ parsed.Bit ?? 0, parsed.StringLength, parsed.ArrayCount);
+ }
+ case ModbusAddressShape.InvalidAddressString:
+ return Refused(shape, "Modbus TagConfig 'addressString' is not a string — the tag will not resolve (BadNodeIdUnknown).");
+ default:
+ return Refused(shape, "Modbus TagConfig names no address: it needs 'region' + 'address' or an 'addressString' — the tag will not resolve (BadNodeIdUnknown).");
+ }
+ }
+ catch (FormatException) { return Refused(shape, "Modbus TagConfig address could not be read — the tag will not resolve (BadNodeIdUnknown)."); }
+ catch (InvalidOperationException) { return Refused(shape, "Modbus TagConfig address could not be read — the tag will not resolve (BadNodeIdUnknown)."); }
+ }
+
+ /// Whether the driver serves the blob's address under at least one family (the family-unaware gates' accept rule).
+ /// The TagConfig JSON.
+ /// when some family resolves it.
+ public static bool ResolvesUnderAnyFamily(string? tagConfig)
+ => AllFamilies.Any(f => Resolve(tagConfig, f.Family, f.Sub).IsResolved);
+
+ ///
+ /// Whether parses, under SOME family, to exactly the given address fields —
+ /// i.e. the string still describes the keys beside it. Used by the CSV import to keep an exported pinned
+ /// row's string through an unchanged round trip while dropping one a typed cell contradicts. It never
+ /// decides the address (the keys do, by the rule above).
+ ///
+ /// The address string.
+ /// The keys' region.
+ /// The keys' offset.
+ /// The keys' data type.
+ /// The keys' byte order.
+ /// The keys' bit index.
+ /// The keys' string length.
+ /// when some family parses the string to those fields.
+ public static bool AddressStringDescribes(string? addressString, ModbusRegion region, int address, ModbusDataType dataType,
+ ModbusByteOrder byteOrder, int bitIndex, int stringLength)
+ {
+ if (string.IsNullOrWhiteSpace(addressString)) return false;
+ foreach (var (family, sub) in AllFamilies)
+ {
+ if (!ModbusAddressParser.TryParse(addressString.Trim(), family, sub, out var p, out _) || p is null) continue;
+ var parsedType = p.Region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs ? ModbusDataType.Bool : p.DataType;
+ if (p.Region == region && p.Offset == address && parsedType == dataType && p.ByteOrder == byteOrder
+ && (p.Bit ?? 0) == bitIndex && p.StringLength == stringLength)
+ return true;
+ }
+ return false;
+ }
+
+ private static ModbusAddressResolution ResolveKeys(JsonElement root)
+ {
+ var addr = root.GetProperty("address");
+ if (addr.ValueKind != JsonValueKind.Number || !addr.TryGetInt32(out var address) || address < 0 || address > ushort.MaxValue)
+ return Refused(ModbusAddressShape.Keys,
+ $"value '{addr.GetRawText()}' for 'address' is not a Modbus register address (an integer 0–{ushort.MaxValue}); the tag will not resolve (BadNodeIdUnknown).");
+ if (!TagConfigJson.TryReadEnumStrict(root, "region", ModbusRegion.HoldingRegisters, out ModbusRegion region))
+ return Refused(ModbusAddressShape.Keys, TagConfigJson.DescribeInvalidEnum(root, "region") ?? "invalid 'region'");
+ if (!TagConfigJson.TryReadEnumStrict(root, "dataType", ModbusTagDefinitionFactory.DefaultDataType(region), out ModbusDataType dataType))
+ return Refused(ModbusAddressShape.Keys, TagConfigJson.DescribeInvalidEnum(root, "dataType") ?? "invalid 'dataType'");
+ if (!TagConfigJson.TryReadEnumStrict(root, "byteOrder", ModbusByteOrder.BigEndian, out ModbusByteOrder byteOrder))
+ return Refused(ModbusAddressShape.Keys, TagConfigJson.DescribeInvalidEnum(root, "byteOrder") ?? "invalid 'byteOrder'");
+ // isArray / arrayLength: an array iff isArray:true AND arrayLength >= 1 (a 1-element array is valid).
+ var isArray = root.TryGetProperty("isArray", out var ia) && ia.ValueKind == JsonValueKind.True;
+ var arrayLength = ReadInt(root, "arrayLength");
+ return Resolved(ModbusAddressShape.Keys, region, address, dataType, byteOrder,
+ (byte)ReadInt(root, "bitIndex"), (ushort)ReadInt(root, "stringLength"), isArray && arrayLength >= 1 ? arrayLength : null);
+ }
+
+ private static ModbusAddressResolution Resolved(ModbusAddressShape shape, ModbusRegion region, int address, ModbusDataType dataType,
+ ModbusByteOrder byteOrder, int bitIndex, int stringLength, int? arrayCount)
+ {
+ // A bit region carries only Bool: coerce, never refuse (pre-#741 coil blobs persisted Int16).
+ if (region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs) dataType = ModbusDataType.Bool;
+ return new ModbusAddressResolution(shape,
+ new ModbusResolvedAddress(shape, region, (ushort)address, dataType, byteOrder, (byte)bitIndex, (ushort)stringLength, arrayCount),
+ null);
+ }
+
+ private static ModbusAddressResolution Refused(ModbusAddressShape shape, string reason) => new(shape, null, reason);
+
+ private static string Describe(ModbusFamily family, MelsecFamily sub)
+ => family == ModbusFamily.MELSEC ? $"MELSEC {sub}" : family.ToString();
+
+ private static int ReadInt(JsonElement o, string name)
+ => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number && e.TryGetInt32(out var v) ? v : 0;
+}
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 02e2ff34..7ae5af32 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,25 @@ 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 — the call ModbusDriver makes with its own Family / MelsecSubFamily. WHICH
+ /// address the blob names is decided by (the one authority shared
+ /// with the deploy gate, the AdminUI editor model and the CSV import/export): region + address
+ /// when both are present, else a string-only addressString resolved with the DRIVER's
+ /// / (refused when that family cannot parse it).
+ /// This method adds only the non-address reads and guards (region/type compatibility, String length,
+ /// BitInRegister index, writable, unitId, deadband, coalesceProhibited).
+ ///
+ /// 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,22 +58,24 @@ 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;
- if (!TagConfigJson.TryReadEnumStrict(root, "dataType", ModbusDataType.Int16, out var dataType)) return false;
- if (!TagConfigJson.TryReadEnumStrict(root, "byteOrder", ModbusByteOrder.BigEndian, out var byteOrder)) return false;
+ if (root.ValueKind != JsonValueKind.Object) return false;
+
+ // The address — region, offset, type, order, bit, length, count — comes from the ONE authority
+ // (ModbusTagAddressResolution: keys when both are present, else the addressString with this family).
+ var resolution = ModbusTagAddressResolution.Resolve(root, family, melsecSubFamily);
+ if (resolution.Address is not { } a) return false;
+ var region = a.Region;
+ var address = a.Address;
+ var dataType = a.DataType;
+ var byteOrder = a.ByteOrder;
+ var bitIndex = a.BitIndex;
+ var stringLength = a.StringLength;
+ var arrayCount = a.ArrayCount;
+ // Region/type compatibility. A bit region's dataType was already COERCED to Bool by the resolver
+ // (the pre-#741 editor persisted Int16 for coil tags and the old driver ignored it). A register
+ // region with Bool is refused: that pair never read (it threw out of the planner).
+ if (!IsRegionTypeCompatible(region, dataType)) 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
@@ -63,14 +84,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);
@@ -82,7 +95,7 @@ public static class ModbusTagDefinitionFactory
var deadband = ReadOptionalDouble(root, "deadband");
var coalesceProhibited = ReadBool(root, "coalesceProhibited");
def = new ModbusTagDefinition(
- Name: rawPath, Region: region, Address: (ushort)address, DataType: dataType,
+ Name: rawPath, Region: region, Address: address, DataType: dataType,
Writable: writable, ByteOrder: byteOrder, BitIndex: bitIndex, StringLength: stringLength,
StringByteOrder: stringByteOrder, WriteIdempotent: false,
ArrayCount: arrayCount, Deadband: deadband, UnitId: unitId,
@@ -94,6 +107,49 @@ 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 ModbusTagAddressResolution.AllFamilies)
+ if (FromTagConfig(tagConfig, rawPath, family, sub, out def)) return true;
+ def = null!;
+ return false;
+ }
+
+ ///
+ /// The key the tag editor writes beside the resolved region / address ("addressResolved": true).
+ /// Display / CSV state only — see .
+ ///
+ public const string AddressResolvedKey = ModbusTagAddressResolution.AddressResolvedKey;
+
+ /// 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.
+ public static ModbusDataType DefaultDataType(ModbusRegion region)
+ => region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs ? ModbusDataType.Bool : ModbusDataType.Int16;
+
+ ///
+ /// Region/type compatibility, the one rule every authoring path and the driver share: Coils and
+ /// DiscreteInputs carry only Bool; HoldingRegisters and InputRegisters carry every register
+ /// type (including BitInRegister) but never Bool.
+ ///
+ /// The tag's region.
+ /// The tag's data type.
+ /// when the driver can read the pair.
+ public static bool IsRegionTypeCompatible(ModbusRegion region, ModbusDataType dataType)
+ => region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs
+ ? dataType == ModbusDataType.Bool
+ : dataType != ModbusDataType.Bool;
+
///
/// Inverse of : serialises a typed definition back to the camelCase
/// TagConfig JSON the mapper reads. Used by authoring paths (the Client CLI) that construct a
@@ -169,7 +225,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
{
@@ -210,6 +266,26 @@ public static class ModbusTagDefinitionFactory
/// The list to append warnings to.
private static void DescribeRejection(JsonElement root, List warnings)
{
+ // Which part of the blob names the address is the resolver's call, never restated here.
+ switch (ModbusTagAddressResolution.Classify(root))
+ {
+ case ModbusAddressShape.InvalidAddressString:
+ warnings.Add("Modbus TagConfig 'addressString' is not a string — the tag will not resolve (BadNodeIdUnknown).");
+ return;
+ case ModbusAddressShape.StringOnly:
+ {
+ // String-only blob: the addressString is what the driver resolves (with its own family).
+ var addressText = root.GetProperty(ModbusTagAddressResolution.AddressStringKey).GetString()!.Trim();
+ 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(
@@ -247,7 +323,17 @@ public static class ModbusTagDefinitionFactory
// Type-dependent guards. The byte/ushort NARROWING is part of the rule, not an accident of the
// mapper: it reads `(byte)bitIndex` / `(ushort)stringLength`, so 256 is bit 0 and is ACCEPTED.
// Describing the un-narrowed value here would call a blob invalid that the driver serves.
- if (!TagConfigJson.TryReadEnumStrict(root, "dataType", ModbusDataType.Int16, out var dataType)) return;
+ var region = TagConfigJson.TryReadEnumStrict(root, "region", ModbusRegion.HoldingRegisters, out var r)
+ ? r : ModbusRegion.HoldingRegisters;
+ if (!TagConfigJson.TryReadEnumStrict(root, "dataType", DefaultDataType(region), out var dataType)) return;
+
+ // Only a register region with Bool is refused (a bit region with any type is coerced to Bool).
+ if (root.TryGetProperty("region", out _) && region is not (ModbusRegion.Coils or ModbusRegion.DiscreteInputs)
+ && !IsRegionTypeCompatible(region, dataType))
+ {
+ warnings.Add($"Modbus Bool tags only live in Coils / DiscreteInputs; a {region} tag must use a register type "
+ + "(use BitInRegister for a bit of a register) — the tag is rejected (BadNodeIdUnknown).");
+ }
if (dataType == ModbusDataType.String && (ushort)ReadInt(root, "stringLength") < 1)
{
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDecodeException.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDecodeException.cs
new file mode 100644
index 00000000..06d9a639
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDecodeException.cs
@@ -0,0 +1,17 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
+
+///
+/// The device answered with well-framed data the tag's codec cannot represent (a non-BCD nibble in a
+/// BCD register, for example). The device is reachable, so this is a per-tag
+/// BadDecodingError with a once-per-tag Warning — never a comms fault, never a health write.
+///
+public sealed class ModbusDecodeException : Exception
+{
+ /// Initializes a new instance of the class.
+ /// A human-readable description.
+ /// The codec's own exception.
+ public ModbusDecodeException(string message, Exception? inner = null)
+ : base(message, inner)
+ {
+ }
+}
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 19e90946..83a28405 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
@@ -43,16 +43,43 @@ public sealed class ModbusDriver
// v3 authored-tag table, keyed by RawPath (the tag identity + driver wire reference). RawPath is
// case-sensitive ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper.
- private readonly Dictionary _tagsByRawPath = new(StringComparer.Ordinal);
+ // BUILD-AND-SWAP: a fresh dictionary is built off to the side and published by reference
+ // (Volatile.Write, under _stateLock with the generation check); it is never mutated or .Clear()ed
+ // in place, because retained poll loops read it concurrently across a re-initialize.
+ private Dictionary _tagsByRawPath = new(StringComparer.Ordinal);
+
+ ///
+ /// Lifecycle generation. Every InitializeAsync / ShutdownAsync bumps it under
+ /// ; every health write and every table/transport publish carries the
+ /// generation it was computed under and is DROPPED once the generation has moved on — so a
+ /// superseded (abandoned) initialize can never publish over a newer one, a read that straddled a
+ /// re-initialize cannot write health about the new transport, and a shutdown's final health
+ /// write cannot clobber a newer init.
+ ///
+ private int _generation;
+
+ /// Guards , the transport/table publish and the health fields.
+ private readonly object _stateLock = new();
+
+ // ONE comms-fault slot for reads AND writes: a fault is a property of the shared transport, so the
+ // latest batch of either kind decides — a good read clears a Degraded a write's timeout raised and vice
+ // versa (two independent sides latched a single write blip as Degraded forever in a read-mostly plant).
+ // LastSuccessfulRead advances on any evidence the device answered — a good read, a good write, a
+ // refusal, or a probe answer.
+ private string? _commsFault;
+
+ // Once-per-tag Warning for deterministic device refusals (exception 01/02/03, undecodable data).
+ // Replaced (never cleared in place) on every table publish.
+ private ConcurrentDictionary _refusalWarned = new(StringComparer.Ordinal);
// Resolves a read/write/subscribe RawPath reference to its tag definition via a single hit on the
// authored _tagsByRawPath table (v3: the reference is always a RawPath; a miss is a miss).
private readonly EquipmentTagRefResolver _resolver;
- // Last-published value per tag, keyed by FullReference. Used by ShouldPublish to apply
- // the deadband filter. Stored as object so all numeric types share one map; the comparison
- // does a typed cast inside.
- private readonly ConcurrentDictionary _lastPublishedByRef = new(StringComparer.OrdinalIgnoreCase);
+ // Last-published (value, status) per tag, keyed by FullReference. Used by ShouldPublish to apply
+ // the deadband filter. The value is stored as object so all numeric types share one map; the
+ // comparison does a typed cast inside. The status is part of the baseline — see ShouldPublish.
+ private readonly ConcurrentDictionary _lastPublishedByRef = new(StringComparer.OrdinalIgnoreCase);
// Per-cycle buffer of the items this driver actually PUBLISHED (i.e. that passed ShouldPublish),
// keyed by subscription handle. The poll engine collects its own batch list unconditionally — it
@@ -87,6 +114,11 @@ public sealed class ModbusDriver
private IModbusTransport? _transport;
private CancellationTokenSource? _probeCts;
private CancellationTokenSource? _reprobeCts;
+ private Task? _probeTask;
+ private Task? _reprobeTask;
+
+ /// Bounded join on the probe / re-probe loops at teardown — they honour their token, so this is a backstop.
+ private static readonly TimeSpan BackgroundLoopJoinTimeout = TimeSpan.FromSeconds(2);
///
/// Guards / /
@@ -105,7 +137,7 @@ public sealed class ModbusDriver
/// (a connect that never returns) must not turn shutdown or dispose into a hang — past the
/// timeout they tear down anyway, which is the lesser evil of the two.
///
- private static readonly TimeSpan LifecycleGateTimeout = TimeSpan.FromSeconds(10);
+ internal TimeSpan LifecycleGateTimeout { get; set; } = TimeSpan.FromSeconds(10);
private DriverHealth _health = new(DriverState.Unknown, null, null);
@@ -152,7 +184,7 @@ public sealed class ModbusDriver
_driverInstanceId = driverInstanceId;
_logger = logger ?? NullLogger.Instance;
_resolver = new EquipmentTagRefResolver(
- r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
+ r => Volatile.Read(ref _tagsByRawPath).TryGetValue(r, out var t) ? t : null);
_transportFactory = transportFactory
?? (o => ModbusTransportFactory.Create(o));
_poll = new PollGroupEngine(
@@ -208,9 +240,149 @@ public sealed class ModbusDriver
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));
+ lock (_stateLock)
+ {
+ // Lifecycle-owned states are left alone: Faulted is a stronger, config-level signal, and an
+ // Initializing/Unknown driver has no transport for a poll to have failed against — the
+ // failure is the re-initialize window itself, which the lifecycle reports on its own.
+ var current = _health;
+ if (current.State is DriverState.Faulted or DriverState.Initializing or DriverState.Unknown) return;
+ _commsFault = ex.Message;
+ PublishComposedHealthLocked(current.LastSuccessfulRead);
+ }
+ }
+
+ // ---- health composition ----
+
+ ///
+ /// Per-batch accounting: did the device answer anything, did a comms fault happen, and which units
+ /// (or the whole endpoint) must the rest of this batch fail fast against.
+ ///
+ private sealed class BatchOutcome
+ {
+ public bool Answered;
+ public string? CommsFault;
+ public bool EndpointDown;
+ private HashSet? _failedUnits;
+
+ public void FailUnit(byte unit) => (_failedUnits ??= []).Add(unit);
+
+ public bool IsFailedFast(byte unit) => EndpointDown || (_failedUnits?.Contains(unit) ?? false);
+ }
+
+ ///
+ /// Applies one batch's outcome to the driver health, under , and only when
+ /// is still current. A batch that touched no device (every reference
+ /// unknown, every write refused before the wire) changes nothing.
+ ///
+ private void ReportOutcome(int generation, BatchOutcome batch, DateTime now)
+ {
+ if (!batch.Answered && batch.CommsFault is null) return;
+ lock (_stateLock)
+ {
+ if (generation != _generation) return;
+ var current = _health;
+ if (current.State is DriverState.Faulted or DriverState.Initializing or DriverState.Unknown) return;
+ _commsFault = batch.CommsFault;
+ PublishComposedHealthLocked(batch.Answered ? now : current.LastSuccessfulRead);
+ }
+ }
+
+ /// Writes Healthy/Degraded from the comms-fault slot. Caller holds .
+ private void PublishComposedHealthLocked(DateTime? lastSuccessfulRead)
+ {
+ var fault = _commsFault;
+ WriteHealth(fault is null
+ ? new DriverHealth(DriverState.Healthy, lastSuccessfulRead, null)
+ : new DriverHealth(DriverState.Degraded, lastSuccessfulRead, fault));
+ }
+
+ ///
+ /// A probe answer proves the device is reachable: advance LastSuccessfulRead without
+ /// touching either fault side (it says nothing about the tags).
+ ///
+ private void NoteDeviceAnswered(int generation)
+ {
+ lock (_stateLock)
+ {
+ if (generation != _generation) return;
+ var current = _health;
+ if (current.State is not (DriverState.Healthy or DriverState.Degraded)) return;
+ WriteHealth(current with { LastSuccessfulRead = DateTime.UtcNow });
+ }
+ }
+
+ /// Lifecycle health write, dropped when has been superseded.
+ private void WriteLifecycleHealth(int generation, DriverHealth health)
+ {
+ lock (_stateLock)
+ {
+ if (generation != _generation) return;
+ _commsFault = null;
+ WriteHealth(health);
+ }
+ }
+
+ ///
+ /// Classifies one failed read/write against the health contract and returns the per-tag status:
+ ///
+ /// Exception 01/02/03 — a deterministic REFUSAL: the device answered; per-tag Bad, a
+ /// once-per-tag Warning, no Degraded (it used to latch Degraded and, if every tag refused,
+ /// trip the 90 s watchdog against a reachable device).
+ /// Exception 04/05/06 — the device answered but is not serving: Degraded, answered.
+ /// Exception 0A/0B (and unknown codes) — the gateway answered for a target that did not:
+ /// Degraded, not answered; 0B fails the rest of that unit's batch fast.
+ /// Undecodable data — the device answered with a value we cannot represent: per-tag
+ /// BadDecodingError, once-per-tag Warning, no Degraded.
+ /// No socket / throttled reconnect — the endpoint is down: Degraded, the rest of the
+ /// batch fails fast.
+ /// Any other transport failure (timeout, desync, EOF) — Degraded; the rest of that
+ /// unit's batch fails fast instead of serialising N timeouts.
+ ///
+ ///
+ private uint ClassifyFailure(Exception ex, byte unit, string tagRef, BatchOutcome batch, bool isWrite)
+ {
+ switch (ex)
+ {
+ case ModbusException mex when IsRefusalCode(mex.ExceptionCode):
+ batch.Answered = true;
+ WarnRefusalOnce(tagRef, isWrite, $"exception 0x{mex.ExceptionCode:X2} (fc 0x{mex.FunctionCode:X2})");
+ return MapModbusExceptionToStatus(mex.ExceptionCode);
+ case ModbusException mex when mex.ExceptionCode is 0x04 or 0x05 or 0x06:
+ batch.Answered = true;
+ batch.CommsFault ??= mex.Message;
+ return MapModbusExceptionToStatus(mex.ExceptionCode);
+ case ModbusException mex:
+ batch.CommsFault ??= mex.Message;
+ if (mex.ExceptionCode == 0x0B) batch.FailUnit(unit);
+ return MapModbusExceptionToStatus(mex.ExceptionCode);
+ case ModbusDecodeException dex:
+ batch.Answered = true;
+ WarnRefusalOnce(tagRef, isWrite, dex.Message);
+ return StatusBadDecodingError;
+ case ModbusTransportUnavailableException or ObjectDisposedException or System.Net.Sockets.SocketException:
+ batch.CommsFault ??= ex.Message;
+ batch.EndpointDown = true;
+ return StatusBadCommunicationError;
+ default:
+ batch.CommsFault ??= ex.Message;
+ // Fail the unit fast only when nothing came back in time (timeout / desync / socket-level IO):
+ // a malformed-but-delivered reply (byte-count mismatch) proves the unit responds, so its
+ // siblings are still tried.
+ if (ex is IOException or TimeoutException or OperationCanceledException) batch.FailUnit(unit);
+ return StatusBadCommunicationError;
+ }
+ }
+
+ /// Exception 01 Illegal Function / 02 Illegal Data Address / 03 Illegal Data Value — the device refused the request.
+ internal static bool IsRefusalCode(byte exceptionCode) => exceptionCode is 0x01 or 0x02 or 0x03;
+
+ private void WarnRefusalOnce(string tagRef, bool isWrite, string detail)
+ {
+ if (!Volatile.Read(ref _refusalWarned).TryAdd((isWrite ? "w:" : "r:") + tagRef, 0)) return;
+ _logger.LogWarning(
+ "Modbus device refused {Operation} of tag {RawPath}: {Detail}. The tag reads/writes Bad; driver health is unaffected (the device answered). Driver={DriverInstanceId}",
+ isWrite ? "a write" : "a read", tagRef, detail, _driverInstanceId);
}
//
@@ -233,31 +405,51 @@ public sealed class ModbusDriver
/// Format a per-slave host string. Multi-slave deployments distinguish breakers by this string.
private string BuildSlaveHostName(byte unitId) => $"{_options.Host}:{_options.Port}/unit{unitId}";
- private bool ShouldPublish(string tagRef, DataValueSnapshot snapshot)
+ ///
+ /// Deadband filter for the subscribe path. The baseline is keyed on (value, status), never
+ /// value alone: a Bad/null publish drops the baseline, and a status change always publishes. A
+ /// device that comes back after a comms drop usually comes back holding the SAME reading, so the
+ /// old value-only comparison (|10 − 10| < deadband) swallowed the Good recovery and left
+ /// the node Bad until the value drifted past the deadband — the #527 trap.
+ ///
+ /// The subscribed reference.
+ /// The snapshot the poll engine is about to raise.
+ /// when the snapshot must be published.
+ internal 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))
+ if (snapshot.Value is null || StatusCodeIsBad(snapshot.StatusCode))
{
- // 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;
+ // A Bad publish always passes AND drops the baseline, so the next Good sample is
+ // compared against nothing and publishes — whatever value it carries.
+ _lastPublishedByRef.TryRemove(tagRef, out _);
+ return true;
+ }
+
+ if (!_lastPublishedByRef.TryGetValue(tagRef, out var prev) || prev.Status != snapshot.StatusCode)
+ {
+ // First sample, or a quality transition (Good ↔ Uncertain): passes unconditionally and
+ // (re)seeds the comparison. The threshold cannot be evaluated across a status change.
+ _lastPublishedByRef[tagRef] = (snapshot.Value, snapshot.StatusCode);
return true;
}
var newD = Convert.ToDouble(snapshot.Value);
- var oldD = Convert.ToDouble(prev);
+ var oldD = Convert.ToDouble(prev.Value);
if (Math.Abs(newD - oldD) < tag.Deadband.Value) return false;
- _lastPublishedByRef[tagRef] = snapshot.Value;
+ _lastPublishedByRef[tagRef] = (snapshot.Value, snapshot.StatusCode);
return true;
}
+ /// OPC UA severity bits 31..30 = 10 ⇒ Bad.
+ private static bool StatusCodeIsBad(uint statusCode) => (statusCode & 0xC0000000u) == 0x80000000u;
+
///
public string DriverInstanceId => _driverInstanceId;
///
@@ -290,11 +482,25 @@ public sealed class ModbusDriver
/// The gate-held body of .
private async Task InitializeCoreAsync(string driverConfigJson, CancellationToken cancellationToken)
{
- WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
+ // Claim a new generation and take the previous transport out of service atomically: any read or
+ // write that captured the old generation can no longer write health, and nothing can capture the
+ // old transport from here on.
+ int generation;
+ IModbusTransport? previous;
+ lock (_stateLock)
+ {
+ generation = ++_generation;
+ previous = _transport;
+ _transport = null;
+ _commsFault = null;
+ WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
+ }
+
+ IModbusTransport? transport = null;
try
{
// Release whatever a PREVIOUS initialize published before building the replacements. All
- // three fields assigned below (_transport, _probeCts, _reprobeCts) were simply overwritten,
+ // fields assigned below (_transport, _probeCts, _reprobeCts) were simply overwritten,
// so a second initialize on a live driver stranded a connected transport plus two running
// background loops that no later dispose could reach. The IDriver contract lets a
// caller re-initialise a live driver without ShutdownAsync, and DriverInstanceActor's retry tick
@@ -303,20 +509,9 @@ public sealed class ModbusDriver
// every ACTIVE SUBSCRIPTION — and nothing re-subscribes them afterwards, because a
// mid-life re-initialize is not a shutdown the OPC UA layer knows to re-drive. A caller
// that genuinely wants the subscriptions gone calls ShutdownAsync. A first initialize
- // sees three nulls, so this block is a no-op on the initial call.
- try { _probeCts?.Cancel(); } catch { }
- _probeCts?.Dispose();
- _probeCts = null;
-
- try { _reprobeCts?.Cancel(); } catch { }
- _reprobeCts?.Dispose();
- _reprobeCts = null;
-
- if (_transport is not null)
- {
- await _transport.DisposeAsync().ConfigureAwait(false);
- _transport = null;
- }
+ // sees nulls, so this block is a no-op on the initial call.
+ await StopBackgroundLoopsAsync().ConfigureAwait(false);
+ if (previous is not null) await previous.DisposeAsync().ConfigureAwait(false);
// 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
@@ -325,17 +520,19 @@ public sealed class ModbusDriver
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.
+ transport = _transportFactory(_options);
+ await transport.ConnectAsync(cancellationToken).ConfigureAwait(false);
+
+ // Build the RawPath → definition table OFF TO THE SIDE 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.
+ var table = new Dictionary(StringComparer.Ordinal);
_skippedTags.BeginRebuild(_options.RawTags.Count);
foreach (var entry in _options.RawTags)
{
- if (ModbusTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
- _tagsByRawPath[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent };
+ if (ModbusTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, _options.Family, _options.MelsecSubFamily, out var def))
+ table[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent };
else
{
_skippedTags.Skip(entry.RawPath, "TagConfig did not map to a definition");
@@ -345,30 +542,77 @@ public sealed class ModbusDriver
}
}
_skippedTags.Commit();
- WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
- // PR 23: kick off the probe loop once the transport is up. Initial state stays
- // Unknown until the first probe tick succeeds — avoids broadcasting a premature
- // Running transition before any register round-trip has happened.
- if (_options.Probe.Enabled)
+ // Publish transport + table + Healthy as ONE generation-checked step. A superseded attempt
+ // (a shutdown/initialize ran while this one was abandoned mid-connect) publishes nothing and
+ // releases its own transport.
+ lock (_stateLock)
{
- _probeCts = new CancellationTokenSource();
- _ = Task.Run(() => ProbeLoopAsync(_probeCts.Token), _probeCts.Token);
- }
+ if (generation != _generation)
+ throw new OperationCanceledException(
+ "Modbus initialize was superseded by a later lifecycle operation; its transport was released");
+ _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));
- if (_options.AutoProhibitReprobeInterval is not null)
- {
- _reprobeCts = new CancellationTokenSource();
- _ = Task.Run(() => ReprobeLoopAsync(_reprobeCts.Token), _reprobeCts.Token);
+ // 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. Started inside the
+ // generation-checked publish so a teardown that bypassed the lifecycle gate can never
+ // run between the publish and the loop start and strand a loop.
+ if (_options.Probe.Enabled)
+ {
+ var cts = new CancellationTokenSource();
+ var probeTransport = transport;
+ _probeCts = cts;
+ _probeTask = Task.Run(() => ProbeLoopAsync(generation, probeTransport, cts.Token), cts.Token);
+ }
+
+ if (_options.AutoProhibitReprobeInterval is not null)
+ {
+ var cts = new CancellationTokenSource();
+ _reprobeCts = cts;
+ _reprobeTask = Task.Run(() => ReprobeLoopAsync(cts.Token), cts.Token);
+ }
}
+ transport = null; // published — owned by the driver now
}
catch (Exception ex)
{
- WriteHealth(new DriverHealth(DriverState.Faulted, null, ex.Message));
+ // A failed attempt releases the transport it built — it is never left half-published.
+ if (transport is not null)
+ {
+ try { await transport.DisposeAsync().ConfigureAwait(false); } catch { }
+ }
+ WriteLifecycleHealth(generation, new DriverHealth(DriverState.Faulted, null, ex.Message));
throw;
}
}
+ /// Cancels, bounded-joins and disposes the probe and re-probe loops. Idempotent.
+ private async Task StopBackgroundLoopsAsync()
+ {
+ var probeCts = _probeCts;
+ var reprobeCts = _reprobeCts;
+ var tasks = new[] { _probeTask, _reprobeTask }.Where(t => t is not null).Cast().ToArray();
+ _probeCts = null;
+ _reprobeCts = null;
+ _probeTask = null;
+ _reprobeTask = null;
+
+ try { probeCts?.Cancel(); } catch { }
+ try { reprobeCts?.Cancel(); } catch { }
+ if (tasks.Length > 0)
+ {
+ try { await Task.WhenAll(tasks).WaitAsync(BackgroundLoopJoinTimeout).ConfigureAwait(false); }
+ catch { /* a loop that ignores its token is abandoned, never awaited forever */ }
+ }
+ probeCts?.Dispose();
+ reprobeCts?.Dispose();
+ }
+
///
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
@@ -415,9 +659,17 @@ public sealed class ModbusDriver
/// .
private async Task ShutdownCoreAsync()
{
- var lastRead = ReadHealth().LastSuccessfulRead;
+ int generation;
+ DateTime? lastRead;
+ lock (_stateLock)
+ {
+ // A shutdown is a lifecycle operation too: it supersedes any in-flight (abandoned) initialize,
+ // and its own final write below is dropped if a NEWER initialize started meanwhile.
+ generation = ++_generation;
+ lastRead = _health.LastSuccessfulRead;
+ }
await TeardownAsync().ConfigureAwait(false);
- WriteHealth(new DriverHealth(DriverState.Unknown, lastRead, null));
+ WriteLifecycleHealth(generation, new DriverHealth(DriverState.Unknown, lastRead, null));
}
///
@@ -469,57 +721,87 @@ public sealed class ModbusDriver
public async Task> ReadAsync(
IReadOnlyList fullReferences, CancellationToken cancellationToken)
{
- var transport = RequireTransport();
+ var (generation, transport, table) = CaptureLifecycle();
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
+ var batch = new BatchOutcome();
- var coalesced = _options.MaxReadGap > 0
- ? await ReadCoalescedAsync(transport, fullReferences, results, now, cancellationToken).ConfigureAwait(false)
+ // MaxReadGap 0 = coalescing OFF (its pre-lane meaning, kept for every persisted config that authored
+ // it); the gap-8 default applies only to an absent key.
+ var coalesced = !_options.DisableReadCoalescing && _options.MaxReadGap > 0
+ ? await ReadCoalescedAsync(transport, table, fullReferences, results, now, batch, cancellationToken).ConfigureAwait(false)
: new HashSet();
for (var i = 0; i < fullReferences.Count; i++)
{
if (coalesced.Contains(i)) continue;
- if (!_resolver.TryResolve(fullReferences[i], out var tag))
+ if (!table.TryGetValue(fullReferences[i], out var tag))
{
results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
continue;
}
+ var unit = ResolveUnitId(tag);
+ if (batch.IsFailedFast(unit))
+ {
+ // Fail fast: this unit (or the whole endpoint) already failed at the transport this batch;
+ // paying the same timeout once per remaining tag would stall the poll N-fold.
+ results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
+ InvalidateWriteCache(fullReferences[i]);
+ 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));
+ batch.Answered = true;
// 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]);
- }
- }
+ InvalidateWriteCacheIfDiverged(fullReferences[i], value);
}
- catch (ModbusException mex)
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
- results[i] = new DataValueSnapshot(null, MapModbusExceptionToStatus(mex.ExceptionCode), null, now);
- WriteHealth(new DriverHealth(DriverState.Degraded, ReadHealth().LastSuccessfulRead, mex.Message));
+ throw;
}
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));
+ results[i] = new DataValueSnapshot(null, ClassifyFailure(ex, unit, fullReferences[i], batch, isWrite: false), null, now);
+ InvalidateWriteCache(fullReferences[i]);
}
}
+
+ // ONE health write per batch, worst-wins over the batch, generation-checked. It used to be
+ // last-writer-wins per tag, so health flapped with tag order.
+ ReportOutcome(generation, batch, now);
return results;
}
+ ///
+ /// Captures the generation, transport and tag table a read/write batch runs against, atomically
+ /// with respect to a lifecycle publish.
+ ///
+ private (int Generation, IModbusTransport Transport, Dictionary Table) CaptureLifecycle()
+ {
+ lock (_stateLock)
+ {
+ var transport = _transport ?? throw new InvalidOperationException("ModbusDriver not initialized");
+ return (_generation, transport, _tagsByRawPath);
+ }
+ }
+
+ /// Runs a codec decode, normalising its failure to so the
+ /// classifier can tell undecodable device data from a transport fault.
+ private static object Decode(Func