Merge lane Modbus: Modbus driver remediation (#741)
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -89,13 +89,24 @@ public sealed class ModbusDriverOptions
|
||||
public bool UseFC16ForSingleRegisterWrites { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// <b>Reserved / no-op</b> 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 <c>false</c>.
|
||||
/// Hard kill-switch for FC23 (Read/Write Multiple Registers): when <c>true</c> the driver never
|
||||
/// emits FC23, even with <see cref="UseFC23ForMultiRegisterWrites"/> set. Default <c>false</c>.
|
||||
/// FC23 is OPT-IN — a config without <see cref="UseFC23ForMultiRegisterWrites"/> keeps the FC16 wire
|
||||
/// behaviour, so the persisted <c>"disableFC23": false</c> every form-saved config carries changes
|
||||
/// nothing on the wire.
|
||||
/// </summary>
|
||||
public bool DisableFC23 { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="DisableFC23"/> is set. Default <c>false</c> = FC16, the historical behaviour.
|
||||
/// </summary>
|
||||
public bool UseFC23ForMultiRegisterWrites { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Interval for the background re-probe loop that retries auto-prohibited
|
||||
/// coalesced ranges. When non-null, every <c>AutoProhibitReprobeInterval</c>
|
||||
@@ -107,20 +118,32 @@ public sealed class ModbusDriverOptions
|
||||
public TimeSpan? AutoProhibitReprobeInterval { get; init; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>0</c> = 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 <c>BitInRegister</c> tags on one status word fold into one read). <c>0</c> = no
|
||||
/// coalescing — an explicitly authored 0 keeps that historical meaning. Default <c>8</c> applies
|
||||
/// only when the key is ABSENT. A range the device refuses is auto-prohibited and read per tag.
|
||||
/// <see cref="DisableReadCoalescing"/> also turns it off; <c>coalesceProhibited</c> exempts one tag.
|
||||
/// </summary>
|
||||
public ushort MaxReadGap { get; init; } = 0;
|
||||
public ushort MaxReadGap { get; init; } = DefaultMaxReadGap;
|
||||
|
||||
/// <summary>The default <see cref="MaxReadGap"/> (shared by the factory DTO and the AdminUI form).</summary>
|
||||
public const ushort DefaultMaxReadGap = 8;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="ModbusFamily.Generic"/> = Modicon-only behaviour preserved by default.
|
||||
/// When <c>true</c>, 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 <c>false</c>.
|
||||
/// </summary>
|
||||
public bool DisableReadCoalescing { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// PLC family hint that drives the parser's family-native branch. A STRING-ONLY tag (an
|
||||
/// <c>addressString</c> with no region/address keys — see <see cref="ModbusTagAddressResolution"/>) 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 <see cref="ModbusFamily.Generic"/> (Modicon / mnemonic grammar).
|
||||
/// Tags authored by region + numeric address are unaffected.
|
||||
/// </summary>
|
||||
public ModbusFamily Family { get; init; } = ModbusFamily.Generic;
|
||||
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Which part of a Modbus <c>TagConfig</c> blob names the tag's address — the output of
|
||||
/// <see cref="ModbusTagAddressResolution.Classify(JsonElement)"/>.
|
||||
/// </summary>
|
||||
public enum ModbusAddressShape
|
||||
{
|
||||
/// <summary>
|
||||
/// Both <c>region</c> and <c>address</c> are present: those keys are the address, whatever
|
||||
/// <c>addressString</c> / <c>addressResolved</c> sit beside them (classic, editor-pinned, or a pre-#741 row
|
||||
/// whose string the old driver never read).
|
||||
/// </summary>
|
||||
Keys,
|
||||
|
||||
/// <summary>
|
||||
/// The keys are not both present and <c>addressString</c> 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.
|
||||
/// </summary>
|
||||
StringOnly,
|
||||
|
||||
/// <summary>The keys are not both present and <c>addressString</c> is present but not a string: refused.</summary>
|
||||
InvalidAddressString,
|
||||
|
||||
/// <summary>Neither the keys nor a usable <c>addressString</c>: refused (the tag names no address).</summary>
|
||||
NoAddress,
|
||||
}
|
||||
|
||||
/// <summary>The address part of a Modbus tag, as the driver will poll it.</summary>
|
||||
/// <param name="Shape">Which part of the blob named it (<see cref="ModbusAddressShape.Keys"/> or <see cref="ModbusAddressShape.StringOnly"/>).</param>
|
||||
/// <param name="Region">The register region.</param>
|
||||
/// <param name="Address">The zero-based offset.</param>
|
||||
/// <param name="DataType">The data type (already coerced to <c>Bool</c> for a bit region).</param>
|
||||
/// <param name="ByteOrder">The word/byte order.</param>
|
||||
/// <param name="BitIndex">The bit index (BitInRegister).</param>
|
||||
/// <param name="StringLength">The string length (String).</param>
|
||||
/// <param name="ArrayCount">The element count, or <c>null</c> for a scalar.</param>
|
||||
public sealed record ModbusResolvedAddress(
|
||||
ModbusAddressShape Shape,
|
||||
ModbusRegion Region,
|
||||
ushort Address,
|
||||
ModbusDataType DataType,
|
||||
ModbusByteOrder ByteOrder,
|
||||
byte BitIndex,
|
||||
ushort StringLength,
|
||||
int? ArrayCount);
|
||||
|
||||
/// <summary>The outcome of <see cref="ModbusTagAddressResolution.Resolve(JsonElement, ModbusFamily, MelsecFamily)"/>.</summary>
|
||||
/// <param name="Shape">The blob's address shape (always set, also on a refusal).</param>
|
||||
/// <param name="Address">The resolved address, or <c>null</c> when the tag is refused.</param>
|
||||
/// <param name="Refusal">Why the tag is refused, or <c>null</c> when it resolved.</param>
|
||||
public readonly record struct ModbusAddressResolution(ModbusAddressShape Shape, ModbusResolvedAddress? Address, string? Refusal)
|
||||
{
|
||||
/// <summary>Whether the driver serves the tag at <see cref="Address"/>.</summary>
|
||||
public bool IsResolved => Address is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>The one authority for which address a Modbus <c>TagConfig</c> names</b> (#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.
|
||||
/// <para>The rule:</para>
|
||||
/// <list type="number">
|
||||
/// <item>When <c>region</c> AND <c>address</c> are both present they are the address
|
||||
/// (<see cref="ModbusAddressShape.Keys"/>). No pre-#741 shape changes the address it resolves to: the old
|
||||
/// driver read the keys only and ignored any <c>addressString</c>.</item>
|
||||
/// <item>Otherwise a non-blank string <c>addressString</c> is the address
|
||||
/// (<see cref="ModbusAddressShape.StringOnly"/>), parsed by <see cref="ModbusAddressParser"/> with the
|
||||
/// DRIVER's family. If that family cannot parse it the tag is REFUSED — never served as HR[0].</item>
|
||||
/// <item>Anything else is refused.</item>
|
||||
/// </list>
|
||||
/// <c>addressResolved</c> is editor display / CSV state only and never changes the shape.
|
||||
/// </summary>
|
||||
public static class ModbusTagAddressResolution
|
||||
{
|
||||
/// <summary>The TagConfig key carrying the parser-grammar address (<c>40001:F:CDAB</c>, <c>V2000</c>, <c>D100</c>).</summary>
|
||||
public const string AddressStringKey = "addressString";
|
||||
|
||||
/// <summary>
|
||||
/// The key the editor writes beside the keys it resolved from an <c>addressString</c>
|
||||
/// (<c>"addressResolved": true</c>). Display / CSV state only: it never decides the address.
|
||||
/// </summary>
|
||||
public const string AddressResolvedKey = "addressResolved";
|
||||
|
||||
/// <summary>Every (family, MELSEC sub-family) a driver can be configured with — the family-unaware gates' search space.</summary>
|
||||
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),
|
||||
];
|
||||
|
||||
/// <summary>Classifies an object-rooted TagConfig by which part of it names the address.</summary>
|
||||
/// <param name="root">The TagConfig root (a non-object is <see cref="ModbusAddressShape.NoAddress"/>).</param>
|
||||
/// <returns>The address shape.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Classifies a TagConfig JSON string (blank / malformed / non-object ⇒ <see cref="ModbusAddressShape.NoAddress"/>).</summary>
|
||||
/// <param name="tagConfig">The TagConfig JSON.</param>
|
||||
/// <returns>The address shape.</returns>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>Resolves a TagConfig JSON string's address under one family.</summary>
|
||||
/// <param name="tagConfig">The TagConfig JSON.</param>
|
||||
/// <param name="family">The driver's PLC family.</param>
|
||||
/// <param name="melsecSubFamily">The driver's MELSEC sub-family.</param>
|
||||
/// <returns>The resolution.</returns>
|
||||
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).");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>region</c> / <c>dataType</c> / <c>byteOrder</c>, or an
|
||||
/// <c>address</c> that is not an integer 0–65535, refuses the tag); a bit region's data type is coerced to
|
||||
/// <c>Bool</c> (pre-#741 editor blobs persisted <c>Int16</c> there and the old driver ignored it).
|
||||
/// </summary>
|
||||
/// <param name="root">The TagConfig root.</param>
|
||||
/// <param name="family">The driver's PLC family.</param>
|
||||
/// <param name="melsecSubFamily">The driver's MELSEC sub-family.</param>
|
||||
/// <returns>The resolution.</returns>
|
||||
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)."); }
|
||||
}
|
||||
|
||||
/// <summary>Whether the driver serves the blob's address under at least one family (the family-unaware gates' accept rule).</summary>
|
||||
/// <param name="tagConfig">The TagConfig JSON.</param>
|
||||
/// <returns><see langword="true"/> when some family resolves it.</returns>
|
||||
public static bool ResolvesUnderAnyFamily(string? tagConfig)
|
||||
=> AllFamilies.Any(f => Resolve(tagConfig, f.Family, f.Sub).IsResolved);
|
||||
|
||||
/// <summary>
|
||||
/// Whether <paramref name="addressString"/> 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).
|
||||
/// </summary>
|
||||
/// <param name="addressString">The address string.</param>
|
||||
/// <param name="region">The keys' region.</param>
|
||||
/// <param name="address">The keys' offset.</param>
|
||||
/// <param name="dataType">The keys' data type.</param>
|
||||
/// <param name="byteOrder">The keys' byte order.</param>
|
||||
/// <param name="bitIndex">The keys' bit index.</param>
|
||||
/// <param name="stringLength">The keys' string length.</param>
|
||||
/// <returns><see langword="true"/> when some family parses the string to those fields.</returns>
|
||||
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<ModbusRegion>(root, "region") ?? "invalid 'region'");
|
||||
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", ModbusTagDefinitionFactory.DefaultDataType(region), out ModbusDataType dataType))
|
||||
return Refused(ModbusAddressShape.Keys, TagConfigJson.DescribeInvalidEnum<ModbusDataType>(root, "dataType") ?? "invalid 'dataType'");
|
||||
if (!TagConfigJson.TryReadEnumStrict(root, "byteOrder", ModbusByteOrder.BigEndian, out ModbusByteOrder byteOrder))
|
||||
return Refused(ModbusAddressShape.Keys, TagConfigJson.DescribeInvalidEnum<ModbusByteOrder>(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;
|
||||
}
|
||||
+112
-26
@@ -32,6 +32,25 @@ public static class ModbusTagDefinitionFactory
|
||||
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
|
||||
/// <returns><see langword="true"/> when <paramref name="tagConfig"/> is a valid Modbus address object.</returns>
|
||||
public static bool FromTagConfig(string tagConfig, string rawPath, out ModbusTagDefinition def)
|
||||
=> FromTagConfig(tagConfig, rawPath, ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out def);
|
||||
|
||||
/// <summary>
|
||||
/// Family-aware overload — the call <c>ModbusDriver</c> makes with its own Family / MelsecSubFamily. WHICH
|
||||
/// address the blob names is decided by <see cref="ModbusTagAddressResolution"/> (the one authority shared
|
||||
/// with the deploy gate, the AdminUI editor model and the CSV import/export): <c>region</c> + <c>address</c>
|
||||
/// when both are present, else a string-only <c>addressString</c> resolved with the DRIVER's
|
||||
/// <paramref name="family"/> / <paramref name="melsecSubFamily"/> (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).
|
||||
/// </summary>
|
||||
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
|
||||
/// <param name="rawPath">The tag's RawPath — becomes the definition's identity (<c>Name</c>).</param>
|
||||
/// <param name="family">The driver's PLC family (from its <c>DriverConfig</c>).</param>
|
||||
/// <param name="melsecSubFamily">The driver's MELSEC sub-family; consulted only for MELSEC.</param>
|
||||
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
|
||||
/// <returns><see langword="true"/> when <paramref name="tagConfig"/> is a valid Modbus address object.</returns>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Family-unaware acceptance, for the gates that do not know the owning driver's family (the deploy
|
||||
/// gate's <see cref="Inspect"/>, 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
|
||||
/// <c>addressString</c> behave exactly like <see cref="FromTagConfig(string,string,out ModbusTagDefinition)"/>.
|
||||
/// </summary>
|
||||
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
|
||||
/// <param name="rawPath">The tag's RawPath.</param>
|
||||
/// <param name="def">The definition under the first family that resolves it.</param>
|
||||
/// <returns><see langword="true"/> when any family's driver would serve the tag.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The key the tag editor writes beside the resolved <c>region</c> / <c>address</c> (<c>"addressResolved": true</c>).
|
||||
/// Display / CSV state only — see <see cref="ModbusTagAddressResolution.AddressResolvedKey"/>.
|
||||
/// </summary>
|
||||
public const string AddressResolvedKey = ModbusTagAddressResolution.AddressResolvedKey;
|
||||
|
||||
/// <summary>The data type an authored tag gets when its <c>dataType</c> key is absent.</summary>
|
||||
/// <param name="region">The tag's region.</param>
|
||||
/// <returns><c>Bool</c> for Coils / DiscreteInputs, <c>Int16</c> for the register regions.</returns>
|
||||
public static ModbusDataType DefaultDataType(ModbusRegion region)
|
||||
=> region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs ? ModbusDataType.Bool : ModbusDataType.Int16;
|
||||
|
||||
/// <summary>
|
||||
/// Region/type compatibility, the one rule every authoring path and the driver share: Coils and
|
||||
/// DiscreteInputs carry only <c>Bool</c>; HoldingRegisters and InputRegisters carry every register
|
||||
/// type (including <c>BitInRegister</c>) but never <c>Bool</c>.
|
||||
/// </summary>
|
||||
/// <param name="region">The tag's region.</param>
|
||||
/// <param name="dataType">The tag's data type.</param>
|
||||
/// <returns><see langword="true"/> when the driver can read the pair.</returns>
|
||||
public static bool IsRegionTypeCompatible(ModbusRegion region, ModbusDataType dataType)
|
||||
=> region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs
|
||||
? dataType == ModbusDataType.Bool
|
||||
: dataType != ModbusDataType.Bool;
|
||||
|
||||
/// <summary>
|
||||
/// Inverse of <see cref="FromTagConfig"/>: serialises a typed definition back to the camelCase
|
||||
/// <c>TagConfig</c> 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
|
||||
/// <param name="warnings">The list to append warnings to.</param>
|
||||
private static void DescribeRejection(JsonElement root, List<string> 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)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>BadDecodingError</c> with a once-per-tag Warning — never a comms fault, never a health write.
|
||||
/// </summary>
|
||||
public sealed class ModbusDecodeException : Exception
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <see cref="ModbusDecodeException"/> class.</summary>
|
||||
/// <param name="message">A human-readable description.</param>
|
||||
/// <param name="inner">The codec's own exception.</param>
|
||||
public ModbusDecodeException(string message, Exception? inner = null)
|
||||
: base(message, inner)
|
||||
{
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -78,22 +78,24 @@ 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,
|
||||
UseFC15ForSingleCoilWrites = dto.UseFC15ForSingleCoilWrites ?? false,
|
||||
UseFC16ForSingleRegisterWrites = dto.UseFC16ForSingleRegisterWrites ?? false,
|
||||
DisableFC23 = dto.DisableFC23 ?? false,
|
||||
UseFC23ForMultiRegisterWrites = dto.UseFC23ForMultiRegisterWrites ?? false,
|
||||
WriteOnChangeOnly = dto.WriteOnChangeOnly ?? false,
|
||||
MaxReadGap = dto.MaxReadGap ?? 0,
|
||||
MaxReadGap = dto.MaxReadGap ?? ModbusDriverOptions.DefaultMaxReadGap,
|
||||
DisableReadCoalescing = dto.DisableReadCoalescing ?? false,
|
||||
Family = dto.Family is null ? ModbusFamily.Generic
|
||||
: ParseEnum<ModbusFamily>(dto.Family, "<driver-level>", driverInstanceId, "Family"),
|
||||
MelsecSubFamily = dto.MelsecSubFamily is null ? MelsecFamily.Q_L_iQR
|
||||
: ParseEnum<MelsecFamily>(dto.MelsecSubFamily, "<driver-level>", driverInstanceId, "MelsecSubFamily"),
|
||||
Transport = dto.Transport is null ? ModbusTransportMode.Tcp
|
||||
: ParseEnum<ModbusTransportMode>(dto.Transport, "<driver-level>", 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
|
||||
@@ -103,22 +105,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,
|
||||
},
|
||||
};
|
||||
@@ -126,6 +128,23 @@ public static class ModbusDriverFactoryExtensions
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One timing knob, accepted in BOTH shapes the config can carry: the <c>*Ms</c> integer keys (CLI,
|
||||
/// seed data, hand-authored JSON) and the TimeSpan-string keys the AdminUI driver form emits — it
|
||||
/// serialises <see cref="ModbusDriverOptions"/> itself, so it writes <c>"timeout":"00:00:07"</c>, not
|
||||
/// <c>timeoutMs</c>. 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.
|
||||
/// </summary>
|
||||
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<T>(string? raw, string? tagName, string driverInstanceId, string field) where T : struct, Enum
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
@@ -155,6 +174,8 @@ public static class ModbusDriverFactoryExtensions
|
||||
public byte? UnitId { get; init; }
|
||||
/// <summary>Gets or sets the timeout in milliseconds.</summary>
|
||||
public int? TimeoutMs { get; init; }
|
||||
/// <summary>Gets or sets the timeout as a TimeSpan string (the AdminUI form's shape).</summary>
|
||||
public JsonElement? Timeout { get; init; }
|
||||
/// <summary>Gets or sets the maximum number of registers per read operation.</summary>
|
||||
public ushort? MaxRegistersPerRead { get; init; }
|
||||
/// <summary>Gets or sets the maximum number of registers per write operation.</summary>
|
||||
@@ -167,10 +188,14 @@ public static class ModbusDriverFactoryExtensions
|
||||
public bool? UseFC16ForSingleRegisterWrites { get; init; }
|
||||
/// <summary>Gets or sets a value indicating whether to disable function code 23.</summary>
|
||||
public bool? DisableFC23 { get; init; }
|
||||
/// <summary>Gets or sets a value indicating whether multi-register writes opt in to FC23 write + read-back.</summary>
|
||||
public bool? UseFC23ForMultiRegisterWrites { get; init; }
|
||||
/// <summary>Gets or sets a value indicating whether to write only on change.</summary>
|
||||
public bool? WriteOnChangeOnly { get; init; }
|
||||
/// <summary>Gets or sets the maximum gap between register addresses for coalescing.</summary>
|
||||
public ushort? MaxReadGap { get; init; }
|
||||
/// <summary>Gets or sets a value indicating whether block-read coalescing is turned off entirely.</summary>
|
||||
public bool? DisableReadCoalescing { get; init; }
|
||||
/// <summary>Gets or sets the Modbus family type.</summary>
|
||||
public string? Family { get; init; }
|
||||
/// <summary>Gets or sets the Melsec subfamily.</summary>
|
||||
@@ -179,6 +204,8 @@ public static class ModbusDriverFactoryExtensions
|
||||
public string? Transport { get; init; }
|
||||
/// <summary>Gets or sets the automatic prohibition reprobei interval in milliseconds.</summary>
|
||||
public int? AutoProhibitReprobeMs { get; init; }
|
||||
/// <summary>Gets or sets the re-probe interval as a TimeSpan string (the AdminUI form's shape).</summary>
|
||||
public JsonElement? AutoProhibitReprobeInterval { get; init; }
|
||||
/// <summary>Gets or sets a value indicating whether automatic reconnection is enabled.</summary>
|
||||
public bool? AutoReconnect { get; init; }
|
||||
/// <summary>Gets or sets the authored raw tags (RawPath + TagConfig blob + WriteIdempotent) the
|
||||
@@ -191,6 +218,8 @@ public static class ModbusDriverFactoryExtensions
|
||||
public ModbusKeepAliveDto? KeepAlive { get; init; }
|
||||
/// <summary>Gets or sets the idle disconnect timeout in milliseconds.</summary>
|
||||
public int? IdleDisconnectMs { get; init; }
|
||||
/// <summary>Gets or sets the idle disconnect timeout as a TimeSpan string (the AdminUI form's shape).</summary>
|
||||
public JsonElement? IdleDisconnectTimeout { get; init; }
|
||||
/// <summary>Gets or sets the reconnect configuration.</summary>
|
||||
public ModbusReconnectDto? Reconnect { get; init; }
|
||||
}
|
||||
@@ -203,6 +232,10 @@ public static class ModbusDriverFactoryExtensions
|
||||
public int? TimeMs { get; init; }
|
||||
/// <summary>Gets or sets the keep-alive interval in milliseconds.</summary>
|
||||
public int? IntervalMs { get; init; }
|
||||
/// <summary>Gets or sets the keep-alive time as a TimeSpan string (the AdminUI form's shape).</summary>
|
||||
public JsonElement? Time { get; init; }
|
||||
/// <summary>Gets or sets the keep-alive interval as a TimeSpan string (the AdminUI form's shape).</summary>
|
||||
public JsonElement? Interval { get; init; }
|
||||
/// <summary>Gets or sets the retry count.</summary>
|
||||
public int? RetryCount { get; init; }
|
||||
}
|
||||
@@ -213,6 +246,10 @@ public static class ModbusDriverFactoryExtensions
|
||||
public int? InitialDelayMs { get; init; }
|
||||
/// <summary>Gets or sets the maximum reconnect delay in milliseconds.</summary>
|
||||
public int? MaxDelayMs { get; init; }
|
||||
/// <summary>Gets or sets the initial reconnect delay as a TimeSpan string (the AdminUI form's shape).</summary>
|
||||
public JsonElement? InitialDelay { get; init; }
|
||||
/// <summary>Gets or sets the maximum reconnect delay as a TimeSpan string (the AdminUI form's shape).</summary>
|
||||
public JsonElement? MaxDelay { get; init; }
|
||||
/// <summary>Gets or sets the backoff multiplier for exponential reconnect delays.</summary>
|
||||
public double? BackoffMultiplier { get; init; }
|
||||
}
|
||||
@@ -225,6 +262,10 @@ public static class ModbusDriverFactoryExtensions
|
||||
public int? IntervalMs { get; init; }
|
||||
/// <summary>Gets or sets the probe timeout in milliseconds.</summary>
|
||||
public int? TimeoutMs { get; init; }
|
||||
/// <summary>Gets or sets the probe interval as a TimeSpan string (the AdminUI form's shape).</summary>
|
||||
public JsonElement? Interval { get; init; }
|
||||
/// <summary>Gets or sets the probe timeout as a TimeSpan string (the AdminUI form's shape).</summary>
|
||||
public JsonElement? Timeout { get; init; }
|
||||
/// <summary>Gets or sets the probe register address.</summary>
|
||||
public ushort? ProbeAddress { get; init; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ public sealed class ModbusRtuOverTcpTransport : IModbusTransport
|
||||
private readonly Stream? _testStream;
|
||||
private readonly TimeSpan _timeout;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private bool _disposed;
|
||||
private int _disposed;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ModbusRtuOverTcpTransport"/> class.</summary>
|
||||
/// <param name="host">The host address or hostname of the Modbus gateway.</param>
|
||||
@@ -90,8 +90,8 @@ public sealed class ModbusRtuOverTcpTransport : IModbusTransport
|
||||
/// <inheritdoc />
|
||||
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(ModbusRtuOverTcpTransport));
|
||||
if (CurrentStream is null) throw new InvalidOperationException("Transport not connected");
|
||||
if (Volatile.Read(ref _disposed) != 0) throw new ObjectDisposedException(nameof(ModbusRtuOverTcpTransport));
|
||||
if (_life is null && _testStream is null) throw new InvalidOperationException("Transport not connected");
|
||||
|
||||
// Single-flight: RTU carries no transaction id, so at most one transaction may be on the bus
|
||||
// at a time — serialise every send/receive under the gate.
|
||||
@@ -101,11 +101,14 @@ public sealed class ModbusRtuOverTcpTransport : IModbusTransport
|
||||
// Proactive idle-disconnect (real socket only): if the socket has been quiet longer than
|
||||
// the configured threshold, tear it down + reconnect before this PDU lands. Defends
|
||||
// against silent NAT / firewall reaping.
|
||||
if (_life is not null && _life.ShouldReconnectForIdle())
|
||||
{
|
||||
if (_life is not null && _life.Stream is not null && _life.ShouldReconnectForIdle())
|
||||
await _life.TearDownAsync().ConfigureAwait(false);
|
||||
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Self-heal (real socket only): a socket torn down by an earlier failure is re-dialled here,
|
||||
// once, throttled by the backoff window — same contract as ModbusTcpTransport.
|
||||
var freshlyConnected = _life is not null && _life.Stream is null;
|
||||
if (freshlyConnected)
|
||||
await _life!.EnsureConnectedAsync(ct).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -113,14 +116,16 @@ public sealed class ModbusRtuOverTcpTransport : IModbusTransport
|
||||
_life?.MarkSuccess();
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex) when (_life is not null && _life.AutoReconnect && ModbusSocketLifecycle.IsSocketLevelFailure(ex))
|
||||
catch (Exception ex) when (!freshlyConnected && _life is not null && _life.AutoReconnect
|
||||
&& !ct.IsCancellationRequested
|
||||
&& ModbusSocketLifecycle.IsSocketLevelFailure(ex))
|
||||
{
|
||||
// Mid-transaction drop: tear down the dead socket, reconnect (with backoff if
|
||||
// configured), resend. Single retry — a second failure propagates so health/status
|
||||
// reflect reality. Never reached in the ForTest seam (a raw injected stream has no
|
||||
// lifecycle to reconnect).
|
||||
// Mid-transaction drop: tear down the dead socket, make ONE throttled reconnect attempt
|
||||
// (never a sleep inside the gate), resend. Single retry — a second failure propagates so
|
||||
// health/status reflect reality. Never reached in the ForTest seam (a raw injected stream
|
||||
// has no lifecycle to reconnect).
|
||||
await _life.TearDownAsync().ConfigureAwait(false);
|
||||
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
|
||||
await _life.EnsureConnectedAsync(ct).ConfigureAwait(false);
|
||||
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
|
||||
_life.MarkSuccess();
|
||||
return result;
|
||||
@@ -182,9 +187,8 @@ public sealed class ModbusRtuOverTcpTransport : IModbusTransport
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
||||
if (_life is not null) await _life.DisposeAsync().ConfigureAwait(false);
|
||||
_gate.Dispose();
|
||||
// _gate is deliberately NOT disposed: a SendAsync still in flight at teardown releases it in its finally.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the raw TCP socket lifecycle shared by every Modbus-over-TCP transport (MBAP and
|
||||
/// RTU-over-TCP alike): the IPv4-preference connect, OS-level keep-alive, geometric-backoff
|
||||
/// reconnect, idle-disconnect tracking, and teardown. It exposes the current
|
||||
/// RTU-over-TCP alike): the IPv4-preference connect, OS-level keep-alive, throttled
|
||||
/// reconnect (one attempt per geometric-backoff window), idle-disconnect tracking, and teardown. It exposes the current
|
||||
/// <see cref="NetworkStream"/> so the composing transport can drive its own framing on top —
|
||||
/// the lifecycle knows nothing about MBAP / RTU wire layout.
|
||||
/// </summary>
|
||||
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
/// Extracted verbatim from <see cref="ModbusTcpTransport"/> so the connect / reconnect /
|
||||
/// keep-alive / idle behaviour is written once and composed by both transports. The
|
||||
/// constructor is connection-free — all socket I/O happens in <see cref="ConnectAsync"/> /
|
||||
/// <see cref="ConnectWithBackoffAsync"/>.
|
||||
/// <see cref="EnsureConnectedAsync"/> (one throttled attempt per call — never a sleep loop).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Why keep-alive matters for DL205/DL260: the AutomationDirect H2-ECOM100 does NOT send
|
||||
@@ -37,6 +37,9 @@ public sealed class ModbusSocketLifecycle : IAsyncDisposable
|
||||
private NetworkStream? _stream;
|
||||
private DateTime _lastSuccessUtc = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Monotonic millisecond clock for the reconnect throttle (test seam).</summary>
|
||||
internal Func<long> NowMs { get; set; } = () => Environment.TickCount64;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ModbusSocketLifecycle"/> class.</summary>
|
||||
/// <param name="host">The host address or hostname of the Modbus server.</param>
|
||||
/// <param name="port">The TCP port of the Modbus server.</param>
|
||||
@@ -71,63 +74,116 @@ public sealed class ModbusSocketLifecycle : IAsyncDisposable
|
||||
/// <returns>A task representing the asynchronous connection operation.</returns>
|
||||
public async Task ConnectAsync(CancellationToken ct)
|
||||
{
|
||||
// Resolve the host explicitly + prefer IPv4. .NET's TcpClient default-constructor is
|
||||
// dual-stack (IPv6 first, fallback to IPv4) — but most Modbus TCP devices (PLCs and
|
||||
// simulators like pymodbus) bind 0.0.0.0 only, so the IPv6 attempt times out and we
|
||||
// burn the entire ConnectAsync budget before even trying IPv4. Resolving first +
|
||||
// dialing the IPv4 address directly sidesteps that.
|
||||
var addresses = await System.Net.Dns.GetHostAddressesAsync(_host, ct).ConfigureAwait(false);
|
||||
var ipv4 = System.Linq.Enumerable.FirstOrDefault(addresses,
|
||||
a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork);
|
||||
var target = ipv4 ?? (addresses.Length > 0 ? addresses[0] : System.Net.IPAddress.Loopback);
|
||||
|
||||
_client = new TcpClient(target.AddressFamily);
|
||||
EnableKeepAlive(_client, _keepAlive);
|
||||
// A previous socket (failed, torn down or idle-reaped) is released FIRST: overwriting _client
|
||||
// used to strand the old TcpClient for the finalizer — up to nine per backoff loop.
|
||||
ThrowIfDisposed();
|
||||
await TearDownAsync().ConfigureAwait(false);
|
||||
|
||||
// DNS resolution runs under the same per-op deadline as the TCP connect: an unresolvable or
|
||||
// slow resolver must not hold the transport gate for the OS resolver's own (much longer) timeout.
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(_timeout);
|
||||
await _client.ConnectAsync(target, _port, cts.Token).ConfigureAwait(false);
|
||||
_stream = _client.GetStream();
|
||||
_lastSuccessUtc = DateTime.UtcNow;
|
||||
TcpClient? client = null;
|
||||
try
|
||||
{
|
||||
// Resolve the host explicitly + prefer IPv4. .NET's TcpClient default-constructor is
|
||||
// dual-stack (IPv6 first, fallback to IPv4) — but most Modbus TCP devices (PLCs and
|
||||
// simulators like pymodbus) bind 0.0.0.0 only, so the IPv6 attempt times out and we
|
||||
// burn the entire ConnectAsync budget before even trying IPv4. Resolving first +
|
||||
// dialing the IPv4 address directly sidesteps that.
|
||||
var addresses = await System.Net.Dns.GetHostAddressesAsync(_host, cts.Token).ConfigureAwait(false);
|
||||
var ipv4 = System.Linq.Enumerable.FirstOrDefault(addresses,
|
||||
a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork);
|
||||
var target = ipv4 ?? (addresses.Length > 0 ? addresses[0] : System.Net.IPAddress.Loopback);
|
||||
|
||||
client = new TcpClient(target.AddressFamily);
|
||||
EnableKeepAlive(client, _keepAlive);
|
||||
await client.ConnectAsync(target, _port, cts.Token).ConfigureAwait(false);
|
||||
ThrowIfDisposed(); // disposed while dialling — the finally releases the fresh client
|
||||
_stream = client.GetStream();
|
||||
_client = client;
|
||||
client = null; // ownership transferred
|
||||
if (Volatile.Read(ref _disposed) != 0)
|
||||
{
|
||||
// A DisposeAsync that ran between the check above and the publish has already torn down;
|
||||
// tear the fresh socket down ourselves so it is never stranded for the finalizer.
|
||||
await TearDownAsync().ConfigureAwait(false);
|
||||
throw new ObjectDisposedException(nameof(ModbusSocketLifecycle));
|
||||
}
|
||||
_lastSuccessUtc = DateTime.UtcNow;
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
// The per-op deadline, not the caller: normalise to a socket-level failure so every
|
||||
// classifier treats a connect timeout as a comms fault.
|
||||
throw new ModbusTransportUnavailableException(
|
||||
$"Modbus connect to {_host}:{_port} timed out after {_timeout.TotalMilliseconds:0}ms");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// A failed attempt disposes its own client — never leave it for the finalizer.
|
||||
client?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Reconnect throttle state. Guarded by the composing transport's single-flight gate (every caller of
|
||||
// EnsureConnectedAsync holds it), so no extra lock is needed.
|
||||
private long _connectNotBeforeMs;
|
||||
private TimeSpan _failureDelay = TimeSpan.Zero;
|
||||
|
||||
/// <summary>
|
||||
/// Connect attempt with the configured geometric backoff. The first attempt fires after
|
||||
/// <see cref="ModbusReconnectOptions.InitialDelay"/> (default zero — immediate); each
|
||||
/// subsequent attempt sleeps for the previous delay times <c>BackoffMultiplier</c>,
|
||||
/// capped at <c>MaxDelay</c>. Caller's cancellation token aborts the loop.
|
||||
/// Ensures a live socket for the next transaction with AT MOST ONE connect attempt, throttled by the
|
||||
/// configured geometric backoff: after a failed attempt, calls inside the backoff window fail fast
|
||||
/// with <see cref="ModbusTransportUnavailableException"/> and never dial. The window starts at
|
||||
/// <see cref="ModbusReconnectOptions.InitialDelay"/> (floored at 1 s) and grows by
|
||||
/// <c>BackoffMultiplier</c> per consecutive failure up to <c>MaxDelay</c>; a successful connect
|
||||
/// resets it. Never sleeps — the composing transport calls it inside its gate, and a sleep there
|
||||
/// used to queue every read, write and probe behind one dead socket for up to ~81 s.
|
||||
/// </summary>
|
||||
/// <param name="ct">A cancellation token to observe for cancellation.</param>
|
||||
/// <returns>A task representing the asynchronous reconnect operation.</returns>
|
||||
public async Task ConnectWithBackoffAsync(CancellationToken ct)
|
||||
/// <returns>A task that completes when a socket is live.</returns>
|
||||
/// <exception cref="ModbusTransportUnavailableException">Auto-reconnect is off, or the call is inside the backoff window.</exception>
|
||||
public async Task EnsureConnectedAsync(CancellationToken ct)
|
||||
{
|
||||
var delay = _reconnect.InitialDelay;
|
||||
var attempt = 0;
|
||||
while (true)
|
||||
if (_stream is not null) return;
|
||||
if (!_autoReconnect)
|
||||
throw new ModbusTransportUnavailableException(
|
||||
$"Modbus transport to {_host}:{_port} is not connected and auto-reconnect is disabled");
|
||||
|
||||
var now = NowMs();
|
||||
if (now < _connectNotBeforeMs)
|
||||
throw new ModbusTransportUnavailableException(
|
||||
$"Modbus reconnect to {_host}:{_port} throttled for another {_connectNotBeforeMs - now}ms after a failed connect");
|
||||
|
||||
try
|
||||
{
|
||||
if (delay > TimeSpan.Zero)
|
||||
await Task.Delay(delay, ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await ConnectAsync(ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (IsSocketLevelFailure(ex) && _autoReconnect)
|
||||
{
|
||||
attempt++;
|
||||
// Geometric growth, capped. Use Math.Min on ticks so we don't overflow with
|
||||
// pathological multipliers / long deployments.
|
||||
var nextTicks = (long)(Math.Max(delay.Ticks, TimeSpan.FromMilliseconds(100).Ticks) * _reconnect.BackoffMultiplier);
|
||||
delay = TimeSpan.FromTicks(Math.Min(nextTicks, _reconnect.MaxDelay.Ticks));
|
||||
if (attempt >= 10)
|
||||
{
|
||||
// Bail after 10 attempts to surface persistent failure to the caller. With
|
||||
// the default backoff (1s base, 2.0x mult, 30s cap) this is roughly 4 minutes
|
||||
// of attempts; with InitialDelay=0 it's immediate up to the same cap.
|
||||
throw;
|
||||
}
|
||||
}
|
||||
await ConnectAsync(ct).ConfigureAwait(false);
|
||||
_failureDelay = TimeSpan.Zero;
|
||||
_connectNotBeforeMs = 0;
|
||||
}
|
||||
catch (Exception) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
_failureDelay = NextDelay(_failureDelay);
|
||||
_connectNotBeforeMs = NowMs() + (long)_failureDelay.TotalMilliseconds;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Minimum reconnect window after a failed connect — a dead device is dialled at most once per second.</summary>
|
||||
private static readonly TimeSpan MinReconnectWindow = TimeSpan.FromSeconds(1);
|
||||
|
||||
private TimeSpan NextDelay(TimeSpan previous)
|
||||
{
|
||||
if (previous <= TimeSpan.Zero)
|
||||
{
|
||||
var first = _reconnect.InitialDelay > MinReconnectWindow ? _reconnect.InitialDelay : MinReconnectWindow;
|
||||
return first < _reconnect.MaxDelay ? first : _reconnect.MaxDelay > TimeSpan.Zero ? _reconnect.MaxDelay : first;
|
||||
}
|
||||
// Geometric growth, capped. Math.Min on ticks so a pathological multiplier cannot overflow.
|
||||
var multiplier = _reconnect.BackoffMultiplier >= 1.0 ? _reconnect.BackoffMultiplier : 1.0;
|
||||
var nextTicks = previous.Ticks * multiplier;
|
||||
var cap = _reconnect.MaxDelay > TimeSpan.Zero ? _reconnect.MaxDelay.Ticks : long.MaxValue;
|
||||
return TimeSpan.FromTicks(nextTicks >= cap ? cap : (long)nextTicks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -211,11 +267,16 @@ public sealed class ModbusSocketLifecycle : IAsyncDisposable
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
_client?.Dispose();
|
||||
// Latch first: a SendAsync still in flight (the driver tearing down under it) must not reconnect
|
||||
// a fresh socket after this point — ConnectAsync checks the latch both before and after dialling.
|
||||
Volatile.Write(ref _disposed, 1);
|
||||
await TearDownAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private int _disposed;
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
if (Volatile.Read(ref _disposed) != 0) throw new ObjectDisposedException(nameof(ModbusSocketLifecycle));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
private readonly TimeSpan _timeout;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private ushort _nextTx;
|
||||
private bool _disposed;
|
||||
private int _disposed;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ModbusTcpTransport"/> class.</summary>
|
||||
/// <param name="host">The host address or hostname of the Modbus server.</param>
|
||||
@@ -56,14 +56,16 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
_life = new ModbusSocketLifecycle(host, port, timeout, autoReconnect, keepAlive, idleDisconnect, reconnect);
|
||||
}
|
||||
|
||||
/// <summary>The socket lifecycle this transport composes (test seam for its clock).</summary>
|
||||
internal ModbusSocketLifecycle Lifecycle => _life;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ConnectAsync(CancellationToken ct) => _life.ConnectAsync(ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(ModbusTcpTransport));
|
||||
if (_life.Stream is null) throw new InvalidOperationException("Transport not connected");
|
||||
if (Volatile.Read(ref _disposed) != 0) throw new ObjectDisposedException(nameof(ModbusTcpTransport));
|
||||
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
@@ -72,11 +74,16 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
// threshold, tear it down + reconnect before this PDU lands. Defends against silent
|
||||
// NAT / firewall reaping where the socket looks alive locally but the upstream side
|
||||
// dropped it minutes ago.
|
||||
if (_life.ShouldReconnectForIdle())
|
||||
{
|
||||
if (_life.Stream is not null && _life.ShouldReconnectForIdle())
|
||||
await _life.TearDownAsync().ConfigureAwait(false);
|
||||
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Self-heal: a socket torn down by an earlier failure (two lost replies in a row, a refused
|
||||
// reconnect) is re-dialled HERE, once, throttled by the backoff window. This used to throw
|
||||
// "Transport not connected" before the gate, so the transport never recovered on its own and
|
||||
// every call failed fast until the 90 s comms-loss watchdog re-initialised the whole driver.
|
||||
var freshlyConnected = _life.Stream is null;
|
||||
if (freshlyConnected)
|
||||
await _life.EnsureConnectedAsync(ct).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -84,13 +91,16 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
_life.MarkSuccess();
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex) when (_life.AutoReconnect && ModbusSocketLifecycle.IsSocketLevelFailure(ex))
|
||||
catch (Exception ex) when (!freshlyConnected && _life.AutoReconnect
|
||||
&& !ct.IsCancellationRequested
|
||||
&& ModbusSocketLifecycle.IsSocketLevelFailure(ex))
|
||||
{
|
||||
// Mid-transaction drop: tear down the dead socket, reconnect (with backoff if
|
||||
// configured), resend. Single retry — if it fails again, let it propagate so
|
||||
// health/status reflect reality.
|
||||
// Mid-transaction drop on a socket that had been live: tear down the dead socket, make ONE
|
||||
// throttled reconnect attempt (never a sleep inside the gate), resend. Single retry — if it
|
||||
// fails again, let it propagate so health/status reflect reality. A socket this call just
|
||||
// dialled is not retried: it proved nothing about a stale connection.
|
||||
await _life.TearDownAsync().ConfigureAwait(false);
|
||||
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
|
||||
await _life.EnsureConnectedAsync(ct).ConfigureAwait(false);
|
||||
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
|
||||
_life.MarkSuccess();
|
||||
return result;
|
||||
@@ -162,10 +172,22 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
var respPdu = new byte[respLen - 1];
|
||||
await ReadExactlyAsync(stream, respPdu, cts.Token).ConfigureAwait(false);
|
||||
|
||||
// Length check before indexing: a PDU must carry at least its function code, and an exception
|
||||
// PDU its exception code too. Indexing past the end used to throw IndexOutOfRangeException —
|
||||
// not an IOException, so no teardown/retry, and a malformed reply read as a driver bug.
|
||||
if (respPdu.Length < 1)
|
||||
throw new ModbusTransportDesyncException(
|
||||
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
|
||||
"Modbus response carried no PDU (MBAP length 1)");
|
||||
|
||||
// Exception PDU: function code has high bit set. This is a well-formed protocol-level
|
||||
// error — the socket is still coherent, so it MUST propagate without teardown.
|
||||
if ((respPdu[0] & 0x80) != 0)
|
||||
{
|
||||
if (respPdu.Length < 2)
|
||||
throw new ModbusTransportDesyncException(
|
||||
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
|
||||
$"Modbus exception response fc=0x{respPdu[0]:X2} carried no exception code");
|
||||
var fc = (byte)(respPdu[0] & 0x7F);
|
||||
var ex = respPdu[1];
|
||||
throw new ModbusException(fc, ex, $"Modbus exception fc={fc} code={ex}");
|
||||
@@ -206,9 +228,8 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
||||
await _life.DisposeAsync().ConfigureAwait(false);
|
||||
_gate.Dispose();
|
||||
// _gate is deliberately NOT disposed: a SendAsync still in flight at teardown releases it in its finally.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when a Modbus transport has no live socket and cannot get one for this call: the one
|
||||
/// in-gate connect attempt failed, the reconnect is inside its backoff window, or auto-reconnect is
|
||||
/// disabled. It says the ENDPOINT is unreachable, so the driver fails the rest of the batch fast
|
||||
/// instead of paying the same failure once per tag.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Derives from <see cref="IOException"/> so every existing socket-level classifier
|
||||
/// (<c>ModbusSocketLifecycle.IsSocketLevelFailure</c>, the driver's comms-fault mapping) treats it as
|
||||
/// a communication failure without extra plumbing.
|
||||
/// </remarks>
|
||||
public sealed class ModbusTransportUnavailableException : IOException
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <see cref="ModbusTransportUnavailableException"/> class.</summary>
|
||||
/// <param name="message">A human-readable description.</param>
|
||||
/// <param name="inner">The originating exception, if any.</param>
|
||||
public ModbusTransportUnavailableException(string message, Exception? inner = null)
|
||||
: base(message, inner)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// The driver refused a write BEFORE anything reached the wire (wrong array length, a payload over
|
||||
/// <c>MaxRegistersPerWrite</c>, an unsupported shape). Carries the OPC UA status the write returns;
|
||||
/// it never touches driver health — nothing was sent.
|
||||
/// </summary>
|
||||
public sealed class ModbusWriteRefusedException : Exception
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <see cref="ModbusWriteRefusedException"/> class.</summary>
|
||||
/// <param name="statusCode">The OPC UA status the write returns.</param>
|
||||
/// <param name="message">A human-readable description.</param>
|
||||
public ModbusWriteRefusedException(uint statusCode, string message)
|
||||
: base(message)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
}
|
||||
|
||||
/// <summary>Gets the OPC UA status the write returns.</summary>
|
||||
public uint StatusCode { get; }
|
||||
}
|
||||
Reference in New Issue
Block a user