Merge lane Modbus: Modbus driver remediation (#741)
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
+21
-3
@@ -80,7 +80,13 @@
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max read gap (coalescing)</label>
|
||||
<InputNumber @bind-Value="_form.MaxReadGap" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">0 = no coalescing. Typical 5–32.</div>
|
||||
<div class="form-text">0 = no coalescing. Default 8. Refused ranges fall back per tag.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.DisableReadCoalescing" @bind-Value:after="EmitAsync" class="form-check-input" id="disableCoalescing" />
|
||||
<label class="form-check-label" for="disableCoalescing">Disable read coalescing</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -106,7 +112,13 @@
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.DisableFC23" @bind-Value:after="EmitAsync" class="form-check-input" id="disableFC23" />
|
||||
<label class="form-check-label" for="disableFC23">Disable FC23 (reserved — no effect today)</label>
|
||||
<label class="form-check-label" for="disableFC23">Disable FC23 (never send FC23, even when opted in below)</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.UseFC23ForMultiRegisterWrites" @bind-Value:after="EmitAsync" class="form-check-input" id="useFC23" />
|
||||
<label class="form-check-label" for="useFC23">Use FC23 write + read-back for multi-register writes (opt-in; default FC16)</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
@@ -311,12 +323,14 @@
|
||||
public int MaxRegistersPerRead { get; set; } = 125;
|
||||
public int MaxRegistersPerWrite { get; set; } = 123;
|
||||
public int MaxCoilsPerRead { get; set; } = 2000;
|
||||
public int MaxReadGap { get; set; } = 0;
|
||||
public int MaxReadGap { get; set; } = ModbusDriverOptions.DefaultMaxReadGap;
|
||||
public bool DisableReadCoalescing { get; set; } = false;
|
||||
|
||||
// Write options
|
||||
public bool UseFC15ForSingleCoilWrites { get; set; } = false;
|
||||
public bool UseFC16ForSingleRegisterWrites { get; set; } = false;
|
||||
public bool DisableFC23 { get; set; } = false;
|
||||
public bool UseFC23ForMultiRegisterWrites { get; set; } = false;
|
||||
public bool WriteOnChangeOnly { get; set; } = false;
|
||||
|
||||
// Keep-alive
|
||||
@@ -357,9 +371,11 @@
|
||||
MaxRegistersPerWrite = o.MaxRegistersPerWrite,
|
||||
MaxCoilsPerRead = o.MaxCoilsPerRead,
|
||||
MaxReadGap = o.MaxReadGap,
|
||||
DisableReadCoalescing = o.DisableReadCoalescing,
|
||||
UseFC15ForSingleCoilWrites = o.UseFC15ForSingleCoilWrites,
|
||||
UseFC16ForSingleRegisterWrites = o.UseFC16ForSingleRegisterWrites,
|
||||
DisableFC23 = o.DisableFC23,
|
||||
UseFC23ForMultiRegisterWrites = o.UseFC23ForMultiRegisterWrites,
|
||||
WriteOnChangeOnly = o.WriteOnChangeOnly,
|
||||
KeepAliveEnabled = o.KeepAlive.Enabled,
|
||||
KeepAliveTimeSeconds = (int)o.KeepAlive.Time.TotalSeconds,
|
||||
@@ -396,9 +412,11 @@
|
||||
UseFC15ForSingleCoilWrites = UseFC15ForSingleCoilWrites,
|
||||
UseFC16ForSingleRegisterWrites = UseFC16ForSingleRegisterWrites,
|
||||
DisableFC23 = DisableFC23,
|
||||
UseFC23ForMultiRegisterWrites = UseFC23ForMultiRegisterWrites,
|
||||
AutoProhibitReprobeInterval = AutoProhibitReprobeIntervalSeconds > 0
|
||||
? TimeSpan.FromSeconds(AutoProhibitReprobeIntervalSeconds) : null,
|
||||
MaxReadGap = (ushort)Math.Clamp(MaxReadGap, 0, 65535),
|
||||
DisableReadCoalescing = DisableReadCoalescing,
|
||||
Family = Family,
|
||||
MelsecSubFamily = MelsecSubFamily,
|
||||
Transport = Transport,
|
||||
|
||||
+81
-8
@@ -4,24 +4,60 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-8"><label class="form-label">Address (text, optional)</label>
|
||||
<input type="text" class="form-control form-control-sm" placeholder="e.g. 40001:F:CDAB, V2000 (DL205), D100 (MELSEC)"
|
||||
value="@_addressText" @onchange="@(e => _addressText = e.Value?.ToString())" /></div>
|
||||
<div class="col-md-4 d-flex align-items-end">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="ResolveAddressAsync">Resolve with driver family</button>
|
||||
</div>
|
||||
@if (_addressError is not null)
|
||||
{
|
||||
<div class="col-12"><div class="form-text text-danger">@_addressError</div></div>
|
||||
}
|
||||
else if (_m.IsStringOnly)
|
||||
{
|
||||
var served = _m.ResolveWith(_family, _melsecSub);
|
||||
<div class="col-12"><div class="form-text text-warning">String-only tag: the driver resolves <code>@_m.AddressString</code> with its own family (here @_family)
|
||||
@if (served.Address is { } a)
|
||||
{
|
||||
<span> → <b>@($"{a.Region}[{a.Address}] {a.DataType}")</b>.</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span> — that family cannot parse it, so the driver <b>refuses</b> the tag.</span>
|
||||
}
|
||||
The fields below are NOT its address — press Resolve to pin them, or edit a field to replace the string.</div></div>
|
||||
}
|
||||
else if (_m.HasLegacyAddressString)
|
||||
{
|
||||
<div class="col-12"><div class="form-text">This tag carries a legacy address string <code>@_m.AddressString</code> that the driver does not use: the fields below are its address. Press Resolve to re-pin it from the string with the driver family.</div></div>
|
||||
}
|
||||
else if (_m.IsPinned)
|
||||
{
|
||||
<div class="col-12"><div class="form-text">Resolved from <code>@_m.AddressString</code> and pinned: the fields below are what the driver polls, so a later family change does not remap the tag. Editing a field drops the string.</div></div>
|
||||
}
|
||||
else if (!_m.HasKeyAddress)
|
||||
{
|
||||
<div class="col-12"><div class="form-text text-warning">This tag names no address yet: set a field below or resolve an address string. The driver refuses it until then.</div></div>
|
||||
}
|
||||
<div class="col-md-4"><label class="form-label">Region</label>
|
||||
<select class="form-select form-select-sm" value="@_m.Region" @onchange="@(e => Update(() => _m.Region = ParseEnum(e.Value, ModbusRegion.HoldingRegisters)))">
|
||||
<select class="form-select form-select-sm" value="@_m.Region" @onchange="@(e => UpdateAddress(() => _m.SetRegion(ParseEnum(e.Value, ModbusRegion.HoldingRegisters))))">
|
||||
@foreach (var v in Enum.GetValues<ModbusRegion>()) { <option value="@v">@v</option> }
|
||||
</select></div>
|
||||
<div class="col-md-2"><label class="form-label">Address</label>
|
||||
<input type="number" class="form-control form-control-sm" value="@_m.Address" @onchange="@(e => Update(() => _m.Address = ParseInt(e.Value)))" /></div>
|
||||
<input type="number" class="form-control form-control-sm" value="@_m.Address" @onchange="@(e => UpdateAddress(() => _m.Address = ParseInt(e.Value)))" /></div>
|
||||
<div class="col-md-3"><label class="form-label">Data type</label>
|
||||
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, ModbusDataType.Int16)))">
|
||||
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => UpdateAddress(() => _m.DataType = ParseEnum(e.Value, ModbusDataType.Int16)))">
|
||||
@foreach (var v in Enum.GetValues<ModbusDataType>()) { <option value="@v">@v</option> }
|
||||
</select></div>
|
||||
<div class="col-md-3"><label class="form-label">Byte order</label>
|
||||
<select class="form-select form-select-sm" value="@_m.ByteOrder" @onchange="@(e => Update(() => _m.ByteOrder = ParseEnum(e.Value, ModbusByteOrder.BigEndian)))">
|
||||
<select class="form-select form-select-sm" value="@_m.ByteOrder" @onchange="@(e => UpdateAddress(() => _m.ByteOrder = ParseEnum(e.Value, ModbusByteOrder.BigEndian)))">
|
||||
@foreach (var v in Enum.GetValues<ModbusByteOrder>()) { <option value="@v">@v</option> }
|
||||
</select></div>
|
||||
<div class="col-md-2"><label class="form-label">Bit index</label>
|
||||
<input type="number" class="form-control form-control-sm" value="@_m.BitIndex" @onchange="@(e => Update(() => _m.BitIndex = ParseInt(e.Value)))" /></div>
|
||||
<input type="number" class="form-control form-control-sm" value="@_m.BitIndex" @onchange="@(e => UpdateAddress(() => _m.BitIndex = ParseInt(e.Value)))" /></div>
|
||||
<div class="col-md-2"><label class="form-label">String len</label>
|
||||
<input type="number" class="form-control form-control-sm" value="@_m.StringLength" @onchange="@(e => Update(() => _m.StringLength = ParseInt(e.Value)))" /></div>
|
||||
<input type="number" class="form-control form-control-sm" value="@_m.StringLength" @onchange="@(e => UpdateAddress(() => _m.StringLength = ParseInt(e.Value)))" /></div>
|
||||
<div class="col-12 mt-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="@(() => _showPicker = true)">Build address</button>
|
||||
</div>
|
||||
@@ -66,6 +102,15 @@
|
||||
if (ConfigJson == _lastConfigJson) { return; }
|
||||
_lastConfigJson = ConfigJson;
|
||||
_m = ModbusTagConfigModel.FromJson(ConfigJson);
|
||||
// Reset the address box only when a DIFFERENT config arrives (another tag in a reused instance) — not
|
||||
// when the parent echoes back what this editor just emitted, which would wipe text the operator typed
|
||||
// but has not resolved yet.
|
||||
if (ConfigJson != _lastEmitted)
|
||||
{
|
||||
_addressText = _m.AddressString;
|
||||
_addressError = null;
|
||||
}
|
||||
(_family, _melsecSub) = ModbusTagConfigModel.ReadFamily(GetDriverConfigJson());
|
||||
}
|
||||
|
||||
private static int ParseInt(object? v, int fallback = 0) => int.TryParse(v?.ToString(), out var i) ? i : fallback;
|
||||
@@ -74,10 +119,38 @@
|
||||
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
|
||||
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
|
||||
|
||||
private string? _lastEmitted;
|
||||
|
||||
private async Task Update(Action apply)
|
||||
{
|
||||
apply();
|
||||
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
|
||||
await EmitAsync();
|
||||
}
|
||||
|
||||
private async Task EmitAsync()
|
||||
{
|
||||
_lastEmitted = _m.ToJson();
|
||||
await ConfigJsonChanged.InvokeAsync(_lastEmitted);
|
||||
}
|
||||
|
||||
private string? _addressText;
|
||||
private string? _addressError;
|
||||
private ModbusFamily _family = ModbusFamily.Generic;
|
||||
private MelsecFamily _melsecSub = MelsecFamily.Q_L_iQR;
|
||||
|
||||
// A hand edit of any resolved field makes those fields authoritative: drop the address string, which the
|
||||
// driver would otherwise re-resolve over them.
|
||||
private Task UpdateAddress(Action apply) => Update(() => { _m.ClearAddressString(); _addressError = null; apply(); });
|
||||
|
||||
// Resolve the typed address with the OWNING driver's family (DL205 octal V-memory, MELSEC D/X/Y) and persist
|
||||
// both the address string and the resolved region/offset/type fields.
|
||||
private async Task ResolveAddressAsync()
|
||||
{
|
||||
var (family, sub) = ModbusTagConfigModel.ReadFamily(GetDriverConfigJson());
|
||||
(_family, _melsecSub) = (family, sub);
|
||||
var error = _m.ResolveAddress(_addressText, family, sub);
|
||||
_addressError = error;
|
||||
if (error is null) await EmitAsync();
|
||||
}
|
||||
|
||||
// Parse the builder string (e.g. "4x00001-1") back into Region + Address fields.
|
||||
@@ -94,7 +167,7 @@
|
||||
var offsetStr = dashIdx >= 0 ? rest[..dashIdx] : rest;
|
||||
if (int.TryParse(offsetStr, out var offset))
|
||||
{
|
||||
await Update(() => { _m.Region = region; _m.Address = offset; });
|
||||
await UpdateAddress(() => { _m.Region = region; _m.Address = offset; });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +188,24 @@ public static class RawTagCsvMapper
|
||||
var json = map?.ApplyTypedColumns(baseJson.Length == 0 ? "{}" : baseJson, row)
|
||||
?? (baseJson.Length == 0 ? "{}" : baseJson);
|
||||
|
||||
// A typed column that handed base keys to the model (CsvTypedColumn.ClearsKeys — Modbus's address
|
||||
// cells and the addressString they contradict) and thereby DROPPED one is reported like any other
|
||||
// override, so the operator sees the string go rather than losing it silently (review r3 m1).
|
||||
if (map is not null)
|
||||
{
|
||||
var mergedKeys = ReadRootKeys(json, out _);
|
||||
foreach (var col in map.Columns)
|
||||
{
|
||||
if (typedUsed.Contains(col.Header)
|
||||
&& col.ClearsKeys is { Count: > 0 } handed
|
||||
&& handed.Any(k => baseKeys.Contains(k) && !mergedKeys.Contains(k))
|
||||
&& !overrodeFallback.Contains(col.Header))
|
||||
{
|
||||
overrodeFallback.Add(col.Header);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Historize flags.
|
||||
var isHistorized = ParseFlag("IsHistorized", Cell("IsHistorized"));
|
||||
json = TagHistorizeConfig.Set(json, isHistorized, Cell("HistorianTagname"));
|
||||
|
||||
@@ -28,11 +28,19 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
/// and <c>fullName</c>/<c>dataItemId</c>/<c>address</c>) — see <see cref="OwnedJsonKeys"/> for why the CSV
|
||||
/// path has to know the whole set and not just the one it writes.
|
||||
/// </param>
|
||||
/// <param name="ClearsKeys">
|
||||
/// Root keys a NON-BLANK cell in this column hands to the MODEL: they are not restored from the base cell
|
||||
/// (the model's serialisation decides them), and they are not owned for export. Modbus's address columns use
|
||||
/// it for <c>addressString</c> / <c>addressResolved</c> — a typed address cell is an authored key address, and
|
||||
/// the model keeps the string (and marker) only while it still describes the cells, dropping one a cell
|
||||
/// contradicts. The import review grid reports a column that dropped such a key.
|
||||
/// </param>
|
||||
public sealed record CsvTypedColumn(
|
||||
string Header,
|
||||
string JsonKey,
|
||||
string? ModelProperty,
|
||||
IReadOnlyList<string>? ReadKeys = null)
|
||||
IReadOnlyList<string>? ReadKeys = null,
|
||||
IReadOnlyList<string>? ClearsKeys = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Every root JSON key this column speaks for, in the <b>runtime's</b> read precedence — i.e.
|
||||
@@ -161,22 +169,28 @@ public sealed class ModelBackedCsvDriverTagMap<TModel> : CsvDriverTagMap
|
||||
private readonly Func<string?, TModel> _fromJson;
|
||||
private readonly Func<TModel, string> _toJson;
|
||||
private readonly IReadOnlyList<CsvModelColumn<TModel>> _columns;
|
||||
private readonly Action<TModel, IReadOnlyList<CsvTypedColumn>>? _afterTypedColumns;
|
||||
|
||||
/// <summary>Builds a model-backed map.</summary>
|
||||
/// <param name="driverType">The driver-type string served.</param>
|
||||
/// <param name="fromJson">The model's <c>FromJson</c> loader (preserves unknown keys).</param>
|
||||
/// <param name="toJson">The model's <c>ToJson</c> serialiser.</param>
|
||||
/// <param name="columns">The typed columns (metadata + read/write pairs).</param>
|
||||
/// <param name="afterTypedColumns">Optional model hook run once after a row's non-blank typed cells were applied
|
||||
/// (given the columns that fired), before serialisation. Modbus uses it to reconcile the address string with
|
||||
/// the authored address cells.</param>
|
||||
public ModelBackedCsvDriverTagMap(
|
||||
string driverType,
|
||||
Func<string?, TModel> fromJson,
|
||||
Func<TModel, string> toJson,
|
||||
IReadOnlyList<CsvModelColumn<TModel>> columns)
|
||||
IReadOnlyList<CsvModelColumn<TModel>> columns,
|
||||
Action<TModel, IReadOnlyList<CsvTypedColumn>>? afterTypedColumns = null)
|
||||
{
|
||||
DriverType = driverType;
|
||||
_fromJson = fromJson;
|
||||
_toJson = toJson;
|
||||
_columns = columns;
|
||||
_afterTypedColumns = afterTypedColumns;
|
||||
Columns = columns.Select(c => c.Meta).ToArray();
|
||||
}
|
||||
|
||||
@@ -223,9 +237,12 @@ public sealed class ModelBackedCsvDriverTagMap<TModel> : CsvDriverTagMap
|
||||
col.Set(model, raw.Trim());
|
||||
fired.Add(col.Meta);
|
||||
foreach (var key in col.Meta.OwnedJsonKeys) { overridden.Add(key); }
|
||||
foreach (var key in col.Meta.ClearsKeys ?? []) { overridden.Add(key); }
|
||||
}
|
||||
}
|
||||
|
||||
if (fired.Count > 0) { _afterTypedColumns?.Invoke(model, fired); }
|
||||
|
||||
var merged = TagConfigJson.ParseOrNew(_toJson(model));
|
||||
foreach (var (key, node) in baseObj)
|
||||
{
|
||||
@@ -459,24 +476,35 @@ public static class CsvColumnMap
|
||||
{
|
||||
var maps = new CsvDriverTagMap[]
|
||||
{
|
||||
// A Modbus tag whose blob has no region + address keys (string-only, or naming no usable address — the
|
||||
// one authority, ModbusTagAddressResolution, decides) exports BLANK address cells: its model fields are
|
||||
// load-time defaults, and exporting them as HoldingRegisters / 0 invited a re-import that authored an
|
||||
// HR[0] address. The driver resolves the string (or refuses the tag).
|
||||
new ModelBackedCsvDriverTagMap<ModbusTagConfigModel>(
|
||||
DriverTypeNames.Modbus, ModbusTagConfigModel.FromJson, m => m.ToJson(),
|
||||
new[]
|
||||
{
|
||||
ModbusCol("Region", "region", nameof(ModbusTagConfigModel.Region),
|
||||
m => m.Region.ToString(), (m, s) => m.Region = CsvValues.ParseEnum<ModbusRegion>("Region", s)),
|
||||
ModbusCol("Address", "address", nameof(ModbusTagConfigModel.Address),
|
||||
m => m.Address.ToString(CultureInfo.InvariantCulture), (m, s) => m.Address = CsvValues.ParseInt("Address", s)),
|
||||
ModbusCol("ModbusDataType", "dataType", nameof(ModbusTagConfigModel.DataType),
|
||||
m => m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum<ModbusDataType>("ModbusDataType", s)),
|
||||
ModbusCol("ByteOrder", "byteOrder", nameof(ModbusTagConfigModel.ByteOrder),
|
||||
m => m.ByteOrder.ToString(), (m, s) => m.ByteOrder = CsvValues.ParseEnum<ModbusByteOrder>("ByteOrder", s)),
|
||||
ModbusCol("BitIndex", "bitIndex", nameof(ModbusTagConfigModel.BitIndex),
|
||||
m => m.BitIndex.ToString(CultureInfo.InvariantCulture), (m, s) => m.BitIndex = CsvValues.ParseInt("BitIndex", s)),
|
||||
ModbusCol("StringLength", "stringLength", nameof(ModbusTagConfigModel.StringLength),
|
||||
m => m.StringLength.ToString(CultureInfo.InvariantCulture), (m, s) => m.StringLength = CsvValues.ParseInt("StringLength", s)),
|
||||
// The six address columns: a non-blank cell is an AUTHORED key address. The string and its
|
||||
// marker survive only while they still describe the cells (ReconcileAddressStringWithKeys,
|
||||
// below) — so an unchanged export → import is lossless and a contradicting cell drops them.
|
||||
ModbusAddrCol("Region", "region", nameof(ModbusTagConfigModel.Region),
|
||||
m => !m.HasKeyAddress ? null : m.Region.ToString(), (m, s) => m.Region = CsvValues.ParseEnum<ModbusRegion>("Region", s)),
|
||||
ModbusAddrCol("Address", "address", nameof(ModbusTagConfigModel.Address),
|
||||
m => !m.HasKeyAddress ? null : m.Address.ToString(CultureInfo.InvariantCulture), (m, s) => m.Address = CsvValues.ParseInt("Address", s)),
|
||||
ModbusAddrCol("ModbusDataType", "dataType", nameof(ModbusTagConfigModel.DataType),
|
||||
m => !m.HasKeyAddress ? null : m.DataType.ToString(), (m, s) => m.DataType = CsvValues.ParseEnum<ModbusDataType>("ModbusDataType", s)),
|
||||
ModbusAddrCol("ByteOrder", "byteOrder", nameof(ModbusTagConfigModel.ByteOrder),
|
||||
m => !m.HasKeyAddress ? null : m.ByteOrder.ToString(), (m, s) => m.ByteOrder = CsvValues.ParseEnum<ModbusByteOrder>("ByteOrder", s)),
|
||||
ModbusAddrCol("BitIndex", "bitIndex", nameof(ModbusTagConfigModel.BitIndex),
|
||||
m => !m.HasKeyAddress ? null : m.BitIndex.ToString(CultureInfo.InvariantCulture), (m, s) => m.BitIndex = CsvValues.ParseInt("BitIndex", s)),
|
||||
ModbusAddrCol("StringLength", "stringLength", nameof(ModbusTagConfigModel.StringLength),
|
||||
m => !m.HasKeyAddress ? null : m.StringLength.ToString(CultureInfo.InvariantCulture), (m, s) => m.StringLength = CsvValues.ParseInt("StringLength", s)),
|
||||
ModbusCol("Writable", "writable", nameof(ModbusTagConfigModel.Writable),
|
||||
m => CsvValues.FormatNullableBool(m.Writable), (m, s) => m.Writable = CsvValues.ParseBool("Writable", s)),
|
||||
},
|
||||
(m, fired) =>
|
||||
{
|
||||
if (fired.Any(c => c.ClearsKeys is { Count: > 0 })) { m.ReconcileAddressStringWithKeys(); }
|
||||
}),
|
||||
|
||||
new ModelBackedCsvDriverTagMap<S7TagConfigModel>(
|
||||
@@ -617,6 +645,11 @@ public static class CsvColumnMap
|
||||
string h, string k, string p, Func<ModbusTagConfigModel, string?> get, Action<ModbusTagConfigModel, string> set)
|
||||
=> new(new CsvTypedColumn(h, k, p), get, set);
|
||||
|
||||
private static CsvModelColumn<ModbusTagConfigModel> ModbusAddrCol(
|
||||
string h, string k, string p, Func<ModbusTagConfigModel, string?> get, Action<ModbusTagConfigModel, string> set)
|
||||
=> new(new CsvTypedColumn(h, k, p, ClearsKeys: ["addressString", ModbusTagDefinitionFactory.AddressResolvedKey]),
|
||||
get, set);
|
||||
|
||||
private static CsvModelColumn<S7TagConfigModel> S7Col(
|
||||
string h, string k, string p, Func<S7TagConfigModel, string?> get, Action<S7TagConfigModel, string> set)
|
||||
=> new(new CsvTypedColumn(h, k, p), get, set);
|
||||
|
||||
@@ -7,29 +7,188 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
/// fields; name/writable live on the Tag entity). Preserves unrecognised JSON keys across a load→save.</summary>
|
||||
public sealed class ModbusTagConfigModel
|
||||
{
|
||||
// Every address field is backed so that SETTING one is an authored address (see _authorsKeys): a hand edit,
|
||||
// a CSV address cell, ResolveAddress or an object initialiser. FromJson assigns the backing fields directly.
|
||||
private ModbusRegion _region = ModbusRegion.HoldingRegisters;
|
||||
private int _address;
|
||||
private ModbusDataType _dataType = ModbusDataType.Int16;
|
||||
private ModbusByteOrder _byteOrder = ModbusByteOrder.BigEndian;
|
||||
private int _bitIndex;
|
||||
private int _stringLength;
|
||||
|
||||
/// <summary>Register region (HoldingRegisters/InputRegisters/Coils/DiscreteInputs).</summary>
|
||||
public ModbusRegion Region { get; set; } = ModbusRegion.HoldingRegisters;
|
||||
public ModbusRegion Region { get => _region; set { _region = value; _authorsKeys = true; } }
|
||||
|
||||
/// <summary>Starting register/coil address.</summary>
|
||||
public int Address { get; set; }
|
||||
public int Address { get => _address; set { _address = value; _authorsKeys = true; } }
|
||||
|
||||
/// <summary>Wire value type the register block decodes to.</summary>
|
||||
public ModbusDataType DataType { get; set; } = ModbusDataType.Int16;
|
||||
public ModbusDataType DataType { get => _dataType; set { _dataType = value; _authorsKeys = true; } }
|
||||
|
||||
/// <summary>Word/byte ordering for multi-register values.</summary>
|
||||
public ModbusByteOrder ByteOrder { get; set; } = ModbusByteOrder.BigEndian;
|
||||
public ModbusByteOrder ByteOrder { get => _byteOrder; set { _byteOrder = value; _authorsKeys = true; } }
|
||||
|
||||
/// <summary>Bit index (0-15) for BitInRegister tags.</summary>
|
||||
public int BitIndex { get; set; }
|
||||
public int BitIndex { get => _bitIndex; set { _bitIndex = value; _authorsKeys = true; } }
|
||||
|
||||
/// <summary>String length in characters for String tags.</summary>
|
||||
public int StringLength { get; set; }
|
||||
public int StringLength { get => _stringLength; set { _stringLength = value; _authorsKeys = true; } }
|
||||
|
||||
/// <summary>Optional writable flag. <c>null</c> ⇒ the key is omitted (driver default true);
|
||||
/// an explicit <c>false</c> makes the equipment tag read-only. Preserved across load→save so an absent
|
||||
/// key stays absent.</summary>
|
||||
public bool? Writable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The address in the parser grammar (<c>40001:F:CDAB</c>, DL205 <c>V2000</c>, MELSEC <c>D100</c>) when the
|
||||
/// tag carries one; <c>null</c> otherwise. Whether it IS the address is not this model's call — it is
|
||||
/// <see cref="AddressShape"/>, asked of <see cref="ModbusTagAddressResolution"/>: the string is the address
|
||||
/// only when the saved blob has no <c>region</c> + <c>address</c> keys (<see cref="IsStringOnly"/>).
|
||||
/// </summary>
|
||||
public string? AddressString { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The editor's <c>"addressResolved": true</c> marker: the keys were resolved FROM <see cref="AddressString"/>
|
||||
/// by <see cref="ResolveAddress"/>. Display / CSV state only — it never makes keys authoritative (keys win
|
||||
/// by presence) and a marker on a blob without keys is ignored (review r3 N3).
|
||||
/// </summary>
|
||||
public bool AddressResolved { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Which part of the blob <see cref="ToJson"/> would save names the address — the one authority's verdict
|
||||
/// (<see cref="ModbusTagAddressResolution.Classify(string)"/>) on this model's own output, so the editor,
|
||||
/// the CSV export and the driver can never disagree about it.
|
||||
/// </summary>
|
||||
public ModbusAddressShape AddressShape => ModbusTagAddressResolution.Classify(ToJson());
|
||||
|
||||
/// <summary>
|
||||
/// A string-only tag (CSV / API authored, no <c>region</c> + <c>address</c> keys): the driver resolves
|
||||
/// <see cref="AddressString"/> with its own family, and the region / offset / type fields are NOT the address
|
||||
/// — <see cref="ToJson"/> never stamps them, so no round trip can turn the tag into HoldingRegisters[0].
|
||||
/// </summary>
|
||||
public bool IsStringOnly => AddressShape == ModbusAddressShape.StringOnly;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the fields are the tag's address: the saved blob carries <c>region</c> + <c>address</c>. False for
|
||||
/// a string-only tag and for a blob that names no usable address (the fields are then load-time defaults
|
||||
/// and must not be presented — or exported — as an address).
|
||||
/// </summary>
|
||||
public bool HasKeyAddress => AddressShape == ModbusAddressShape.Keys;
|
||||
|
||||
/// <summary>The string was resolved into the keys and pinned (marker + keys).</summary>
|
||||
public bool IsPinned => AddressString is not null && AddressResolved && HasKeyAddress;
|
||||
|
||||
/// <summary>
|
||||
/// Keys beside an unmarked <see cref="AddressString"/> — a pre-#741 row (the old driver never read the
|
||||
/// string). The keys are the address; the string is informational only.
|
||||
/// </summary>
|
||||
public bool HasLegacyAddressString => AddressString is not null && !AddressResolved && HasKeyAddress;
|
||||
|
||||
/// <summary>What the driver serves this tag as under a family — the one authority, on the blob this model saves.</summary>
|
||||
/// <param name="family">The driver's PLC family.</param>
|
||||
/// <param name="melsecSubFamily">The driver's MELSEC sub-family.</param>
|
||||
/// <returns>The resolution (refused when the family cannot parse a string-only tag).</returns>
|
||||
public ModbusAddressResolution ResolveWith(ModbusFamily family, MelsecFamily melsecSubFamily)
|
||||
=> ModbusTagAddressResolution.Resolve(ToJson(), family, melsecSubFamily);
|
||||
|
||||
// Whether ToJson writes the six address fields: the loaded blob carried the keys (or named no address at all
|
||||
// AND the operator then authored a field), or an address field was set since. Never true for a string-only or
|
||||
// invalid-string blob that nobody edited: the editor must not write default keys it did not resolve.
|
||||
private bool _authorsKeys = true;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an address string through <see cref="ModbusAddressParser"/> with the owning driver's
|
||||
/// family and copies the result into the region / offset / type / bit / string-length / byte-order /
|
||||
/// array fields — the "persist the resolved region/offset" half of the family wiring, so the blob is
|
||||
/// readable on its own and the editor shows what the driver will poll.
|
||||
/// </summary>
|
||||
/// <param name="text">The address text the operator typed.</param>
|
||||
/// <param name="family">The driver's PLC family.</param>
|
||||
/// <param name="melsecSubFamily">The driver's MELSEC sub-family.</param>
|
||||
/// <returns><c>null</c> on success, else the parser's error.</returns>
|
||||
public string? ResolveAddress(string? text, ModbusFamily family, MelsecFamily melsecSubFamily)
|
||||
{
|
||||
if (!ModbusAddressParser.TryParse(text, family, melsecSubFamily, out var parsed, out var error) || parsed is null)
|
||||
return error ?? "Address did not parse.";
|
||||
AddressString = text!.Trim();
|
||||
AddressResolved = true;
|
||||
_authorsKeys = true;
|
||||
Region = parsed.Region;
|
||||
Address = parsed.Offset;
|
||||
DataType = parsed.DataType;
|
||||
ByteOrder = parsed.ByteOrder;
|
||||
BitIndex = parsed.Bit ?? 0;
|
||||
StringLength = parsed.StringLength;
|
||||
if (parsed.ArrayCount is { } count)
|
||||
{
|
||||
TagConfigJson.Set(_bag, "isArray", true);
|
||||
TagConfigJson.Set(_bag, "arrayLength", count);
|
||||
}
|
||||
else
|
||||
{
|
||||
_bag.Remove("isArray");
|
||||
_bag.Remove("arrayLength");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Drops the address string so the hand-edited region / address fields are what the driver reads.</summary>
|
||||
public void ClearAddressString()
|
||||
{
|
||||
AddressString = null;
|
||||
AddressResolved = false;
|
||||
_authorsKeys = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// After a CSV import's typed address cells were applied (they are an authored key address): keeps the
|
||||
/// <see cref="AddressString"/> (and its marker) only when it still describes those fields under some family
|
||||
/// — so an unchanged export → import of a pinned row is lossless (review r3 m1) — and drops it when a cell
|
||||
/// contradicts it. Either way the keys are the address; this only decides the metadata.
|
||||
/// </summary>
|
||||
public void ReconcileAddressStringWithKeys()
|
||||
{
|
||||
if (AddressString is null) return;
|
||||
if (!ModbusTagAddressResolution.AddressStringDescribes(AddressString, Region, Address, DataType, ByteOrder, BitIndex, StringLength))
|
||||
ClearAddressString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the owning driver's <c>family</c> / <c>melsecSubFamily</c> from its DriverConfig JSON (keys
|
||||
/// matched case-insensitively, like the driver's DTO binding); Generic / Q_L_iQR when absent or unknown.
|
||||
/// </summary>
|
||||
/// <param name="driverConfigJson">The driver's DriverConfig JSON.</param>
|
||||
/// <returns>The family pair the driver will resolve address strings with.</returns>
|
||||
public static (ModbusFamily Family, MelsecFamily MelsecSubFamily) ReadFamily(string? driverConfigJson)
|
||||
{
|
||||
var family = ModbusFamily.Generic;
|
||||
var sub = MelsecFamily.Q_L_iQR;
|
||||
var o = TagConfigJson.ParseOrNew(driverConfigJson);
|
||||
foreach (var (key, node) in o)
|
||||
{
|
||||
var text = node is JsonValue v && v.TryGetValue<string>(out var str) ? str : null;
|
||||
if (text is null) continue;
|
||||
if (string.Equals(key, "family", StringComparison.OrdinalIgnoreCase)
|
||||
&& Enum.TryParse<ModbusFamily>(text, ignoreCase: true, out var f)) family = f;
|
||||
else if (string.Equals(key, "melsecSubFamily", StringComparison.OrdinalIgnoreCase)
|
||||
&& Enum.TryParse<MelsecFamily>(text, ignoreCase: true, out var m)) sub = m;
|
||||
}
|
||||
return (family, sub);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Changes the region and keeps the data type compatible with it (the driver refuses a bit region
|
||||
/// with a non-Bool type, and a register region with Bool): switching to Coils / DiscreteInputs selects
|
||||
/// Bool, switching from them back to a register region selects Int16.
|
||||
/// </summary>
|
||||
/// <param name="region">The newly selected region.</param>
|
||||
public void SetRegion(ModbusRegion region)
|
||||
{
|
||||
Region = region;
|
||||
if (!ModbusTagDefinitionFactory.IsRegionTypeCompatible(region, DataType))
|
||||
DataType = ModbusTagDefinitionFactory.DefaultDataType(region);
|
||||
}
|
||||
|
||||
private JsonObject _bag = new();
|
||||
|
||||
// The blob this model was LOADED FROM, or null when it was built by object initialiser rather than
|
||||
@@ -44,16 +203,29 @@ public sealed class ModbusTagConfigModel
|
||||
public static ModbusTagConfigModel FromJson(string? json)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
var shape = ModbusTagAddressResolution.Classify(TagConfigJson.Serialize(o));
|
||||
var region = TagConfigJson.GetEnum(o, "region", ModbusRegion.HoldingRegisters);
|
||||
return new ModbusTagConfigModel
|
||||
{
|
||||
_source = json ?? "",
|
||||
Region = TagConfigJson.GetEnum(o, "region", ModbusRegion.HoldingRegisters),
|
||||
Address = TagConfigJson.GetInt(o, "address"),
|
||||
DataType = TagConfigJson.GetEnum(o, "dataType", ModbusDataType.Int16),
|
||||
ByteOrder = TagConfigJson.GetEnum(o, "byteOrder", ModbusByteOrder.BigEndian),
|
||||
BitIndex = TagConfigJson.GetInt(o, "bitIndex"),
|
||||
StringLength = TagConfigJson.GetInt(o, "stringLength"),
|
||||
_region = region,
|
||||
_address = TagConfigJson.GetInt(o, "address"),
|
||||
// Absent dataType defaults per region, exactly as the driver's factory does (Bool for a bit region),
|
||||
// and a bit region shows Bool whatever was persisted — the driver coerces a legacy coil/DI
|
||||
// "dataType":"Int16" (what the pre-#741 editor saved) to Bool, so the editor shows what is polled.
|
||||
_dataType = region is ModbusRegion.Coils or ModbusRegion.DiscreteInputs
|
||||
? ModbusDataType.Bool
|
||||
: TagConfigJson.GetEnum(o, "dataType", ModbusDataType.Int16),
|
||||
_byteOrder = TagConfigJson.GetEnum(o, "byteOrder", ModbusByteOrder.BigEndian),
|
||||
_bitIndex = TagConfigJson.GetInt(o, "bitIndex"),
|
||||
_stringLength = TagConfigJson.GetInt(o, "stringLength"),
|
||||
Writable = TagConfigJson.GetBoolNullable(o, "writable"),
|
||||
AddressString = string.IsNullOrWhiteSpace(TagConfigJson.GetString(o, ModbusTagAddressResolution.AddressStringKey))
|
||||
? null : TagConfigJson.GetString(o, ModbusTagAddressResolution.AddressStringKey),
|
||||
AddressResolved = TagConfigJson.GetBoolNullable(o, ModbusTagAddressResolution.AddressResolvedKey) == true,
|
||||
// The one authority decides whether the loaded fields are an address: only a blob with the keys is.
|
||||
// A string-only / invalid-string / address-less blob keeps its keys unwritten until a field is authored.
|
||||
_authorsKeys = shape == ModbusAddressShape.Keys,
|
||||
_bag = o,
|
||||
};
|
||||
}
|
||||
@@ -63,14 +235,28 @@ public sealed class ModbusTagConfigModel
|
||||
/// <returns>The serialised TagConfig JSON string.</returns>
|
||||
public string ToJson()
|
||||
{
|
||||
TagConfigJson.Set(_bag, "region", Region);
|
||||
TagConfigJson.Set(_bag, "address", Address);
|
||||
TagConfigJson.Set(_bag, "dataType", DataType);
|
||||
TagConfigJson.Set(_bag, "byteOrder", ByteOrder);
|
||||
TagConfigJson.Set(_bag, "bitIndex", BitIndex);
|
||||
TagConfigJson.Set(_bag, "stringLength", StringLength);
|
||||
TagConfigJson.Set(_bag, "writable", Writable);
|
||||
return TagConfigJson.Serialize(_bag);
|
||||
var o = (JsonObject)_bag.DeepClone();
|
||||
// Only an authored key address is stamped (see _authorsKeys). A string-only tag's fields are load-time
|
||||
// defaults, not its address: stamping them would sit HoldingRegisters/0 beside the string, and by the key
|
||||
// rule that would WIN (review r3 N3).
|
||||
if (_authorsKeys)
|
||||
{
|
||||
TagConfigJson.Set(o, "region", Region);
|
||||
TagConfigJson.Set(o, "address", Address);
|
||||
TagConfigJson.Set(o, "dataType", DataType);
|
||||
TagConfigJson.Set(o, "byteOrder", ByteOrder);
|
||||
TagConfigJson.Set(o, "bitIndex", BitIndex);
|
||||
TagConfigJson.Set(o, "stringLength", StringLength);
|
||||
}
|
||||
TagConfigJson.Set(o, "writable", Writable);
|
||||
// A non-string addressString the model could not load is left as persisted (the driver refuses it, and
|
||||
// dropping it would change the deploy gate's explanation) unless the address was authored since.
|
||||
if (AddressString is not null || _authorsKeys)
|
||||
TagConfigJson.Set(o, ModbusTagAddressResolution.AddressStringKey, AddressString);
|
||||
// The marker only means something beside keys; a marker on a keyless blob is a stale lie and is dropped.
|
||||
TagConfigJson.Set(o, ModbusTagAddressResolution.AddressResolvedKey,
|
||||
AddressString is not null && AddressResolved && _authorsKeys ? true : null);
|
||||
return TagConfigJson.Serialize(o);
|
||||
}
|
||||
|
||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||
@@ -103,13 +289,15 @@ public sealed class ModbusTagConfigModel
|
||||
/// </remarks>
|
||||
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
|
||||
public string? Validate()
|
||||
=> TagConfigJson.DescribeInvalidEnum<ModbusRegion>(_bag, "region")
|
||||
?? TagConfigJson.DescribeInvalidEnum<ModbusDataType>(_bag, "dataType")
|
||||
?? TagConfigJson.DescribeInvalidEnum<ModbusByteOrder>(_bag, "byteOrder")
|
||||
// A string-only tag's region / dataType / byteOrder keys are not read by the driver (the string is the
|
||||
// address), so judging them would make authoring stricter than the runtime (review r2 n6).
|
||||
=> (IsStringOnly ? null : TagConfigJson.DescribeInvalidEnum<ModbusRegion>(_bag, "region"))
|
||||
?? (IsStringOnly ? null : TagConfigJson.DescribeInvalidEnum<ModbusDataType>(_bag, "dataType"))
|
||||
?? (IsStringOnly ? null : TagConfigJson.DescribeInvalidEnum<ModbusByteOrder>(_bag, "byteOrder"))
|
||||
?? TagConfigJson.DescribeInvalidEnum<ModbusStringByteOrder>(_bag, "stringByteOrder")
|
||||
?? TagConfigParseGate.Describe(
|
||||
_source,
|
||||
j => ModbusTagDefinitionFactory.FromTagConfig(j, TagConfigParseGate.AuthoringRawPath, out _),
|
||||
j => ModbusTagDefinitionFactory.FromTagConfigAnyFamily(j, TagConfigParseGate.AuthoringRawPath, out _),
|
||||
ModbusTagDefinitionFactory.Inspect,
|
||||
"Modbus");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Diagnostics;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Comms-loss → recovery against the real pymodbus <c>standard</c> fixture, with a loopback
|
||||
/// <see cref="TcpRelay"/> in front of it so the device can disappear and come back without touching the
|
||||
/// shared container. Covers the review 07 High findings P3 (transport self-heal) and H2 (deadband
|
||||
/// recovery publish).
|
||||
/// </summary>
|
||||
[Collection(ModbusSimulatorCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "Standard")]
|
||||
public sealed class CommsRecoveryTests(ModbusSimulatorFixture sim)
|
||||
{
|
||||
private const uint StatusGood = 0u;
|
||||
|
||||
private void SkipUnlessStandardLive()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
var profile = Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE");
|
||||
if (profile is not null && !string.Equals(profile, "standard", StringComparison.OrdinalIgnoreCase))
|
||||
Assert.Skip($"MODBUS_SIM_PROFILE={profile} — these tests need the standard profile (HR[0..31] = address).");
|
||||
}
|
||||
|
||||
private ModbusDriverOptions Options(TcpRelay relay, params ModbusTagDefinition[] tags) => new()
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = relay.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
RawTags = ModbusRawTags.Entries(tags),
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
|
||||
private static async Task<T> WaitFor<T>(Func<Task<T>> probe, Func<T, bool> done, TimeSpan budget)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
T last;
|
||||
do
|
||||
{
|
||||
last = await probe();
|
||||
if (done(last)) return last;
|
||||
await Task.Delay(200, TestContext.Current.CancellationToken);
|
||||
} while (sw.Elapsed < budget);
|
||||
return last;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// P3: after the device drops (two failed calls in a row, socket torn down, reconnect refused) the
|
||||
/// transport must reconnect ON ITS OWN once the device is back — before this fix every later call
|
||||
/// failed fast with "Transport not connected" until the 90 s comms-loss watchdog re-initialised the
|
||||
/// driver.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Driver_reads_again_after_the_device_drops_and_returns_without_reinit()
|
||||
{
|
||||
SkipUnlessStandardLive();
|
||||
await using var relay = new TcpRelay(sim.Host, sim.Port);
|
||||
var tag = new ModbusTagDefinition("HR5", ModbusRegion.HoldingRegisters, 5, ModbusDataType.UInt16, Writable: false);
|
||||
await using var driver = new ModbusDriver(Options(relay, tag), "modbus-p3");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var good = await driver.ReadAsync(["HR5"], TestContext.Current.CancellationToken);
|
||||
good[0].StatusCode.ShouldBe(StatusGood);
|
||||
good[0].Value.ShouldBe((ushort)5);
|
||||
|
||||
relay.Cut();
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var bad = await driver.ReadAsync(["HR5"], TestContext.Current.CancellationToken);
|
||||
bad[0].StatusCode.ShouldNotBe(StatusGood);
|
||||
}
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
relay.Restore();
|
||||
var recovered = await WaitFor(
|
||||
() => driver.ReadAsync(["HR5"], TestContext.Current.CancellationToken),
|
||||
r => r[0].StatusCode == StatusGood,
|
||||
TimeSpan.FromSeconds(20));
|
||||
recovered[0].StatusCode.ShouldBe(StatusGood, "the transport must reconnect by itself once the device is back");
|
||||
recovered[0].Value.ShouldBe((ushort)5);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// U1 (FC23 opt-in, DisableFC23 kill-switch): a multi-register write on the opted-in FC23 write + read-back path
|
||||
/// round-trips against the real server — whether it implements FC23 or answers Illegal Function and
|
||||
/// is moved to FC16 — and so does the default FC16 path. Uses the standard profile's scratch HR[200..209].
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(true, 200)]
|
||||
[InlineData(false, 202)]
|
||||
public async Task Multi_register_write_round_trips_with_and_without_FC23(bool useFc23, int address)
|
||||
{
|
||||
SkipUnlessStandardLive();
|
||||
var tag = new ModbusTagDefinition("F", ModbusRegion.HoldingRegisters, (ushort)address, ModbusDataType.Float32);
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host, Port = sim.Port, UnitId = 1, Timeout = TimeSpan.FromSeconds(2), UseFC23ForMultiRegisterWrites = useFc23,
|
||||
RawTags = ModbusRawTags.Entries([tag]), Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var driver = new ModbusDriver(opts, "modbus-u1");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var value = useFc23 ? 6.5f : 12.25f;
|
||||
for (var i = 0; i < 2; i++) // second write exercises the latched path when the server lacks FC23
|
||||
{
|
||||
var w = await driver.WriteAsync([new WriteRequest("F", value + i)], TestContext.Current.CancellationToken);
|
||||
w[0].StatusCode.ShouldBe(StatusGood);
|
||||
var r = await driver.ReadAsync(["F"], TestContext.Current.CancellationToken);
|
||||
r[0].Value.ShouldBe(value + i);
|
||||
}
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// H2: a deadbanded tag whose device returns holding the SAME value must publish the Good recovery.
|
||||
/// HR[5] is a constant 5 in the standard profile, so the recovery value is identical by construction.
|
||||
/// Positive control: the Bad publish during the outage is observed first.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Deadbanded_subscription_publishes_the_Good_recovery_with_an_identical_value()
|
||||
{
|
||||
SkipUnlessStandardLive();
|
||||
await using var relay = new TcpRelay(sim.Host, sim.Port);
|
||||
var tag = new ModbusTagDefinition("HR5", ModbusRegion.HoldingRegisters, 5, ModbusDataType.UInt16,
|
||||
Writable: false, Deadband: 10.0);
|
||||
await using var driver = new ModbusDriver(Options(relay, tag), "modbus-h2");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var published = new System.Collections.Concurrent.ConcurrentQueue<DataValueSnapshot>();
|
||||
driver.OnDataChange += (_, e) => published.Enqueue(e.Snapshot);
|
||||
var handle = await driver.SubscribeAsync(["HR5"], TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
||||
try
|
||||
{
|
||||
(await WaitFor(() => Task.FromResult(published.ToArray()),
|
||||
p => p.Any(s => s.StatusCode == StatusGood), TimeSpan.FromSeconds(10)))
|
||||
.ShouldContain(s => s.StatusCode == StatusGood, "initial Good publish");
|
||||
|
||||
relay.Cut();
|
||||
(await WaitFor(() => Task.FromResult(published.ToArray()),
|
||||
p => p.Any(s => s.StatusCode != StatusGood), TimeSpan.FromSeconds(20)))
|
||||
.ShouldContain(s => s.StatusCode != StatusGood, "the outage must publish Bad (positive control)");
|
||||
|
||||
var badCount = published.Count;
|
||||
relay.Restore();
|
||||
var after = await WaitFor(() => Task.FromResult(published.Skip(badCount).ToArray()),
|
||||
p => p.Any(s => s.StatusCode == StatusGood), TimeSpan.FromSeconds(30));
|
||||
var recovery = after.FirstOrDefault(s => s.StatusCode == StatusGood);
|
||||
recovery.ShouldNotBeNull("the Good recovery with the same value must not be swallowed by the deadband");
|
||||
recovery.Value.ShouldBe((ushort)5);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Loopback TCP relay in front of the real Modbus fixture, so a test can make the device "disappear"
|
||||
/// (<see cref="Cut"/>: every relayed connection is closed and new connects are refused) and "come back"
|
||||
/// (<see cref="Restore"/>: listen again on the SAME port) without touching the shared container.
|
||||
/// </summary>
|
||||
internal sealed class TcpRelay : IAsyncDisposable
|
||||
{
|
||||
private readonly string _upstreamHost;
|
||||
private readonly int _upstreamPort;
|
||||
private readonly object _lock = new();
|
||||
private readonly List<TcpClient> _live = [];
|
||||
private TcpListener? _listener;
|
||||
private CancellationTokenSource _stop = new();
|
||||
private int _accepted;
|
||||
|
||||
public TcpRelay(string upstreamHost, int upstreamPort)
|
||||
{
|
||||
_upstreamHost = upstreamHost;
|
||||
_upstreamPort = upstreamPort;
|
||||
var probe = new TcpListener(IPAddress.Loopback, 0);
|
||||
probe.Start();
|
||||
Port = ((IPEndPoint)probe.LocalEndpoint).Port;
|
||||
probe.Stop();
|
||||
Restore();
|
||||
}
|
||||
|
||||
/// <summary>Gets the loopback port clients dial.</summary>
|
||||
public int Port { get; }
|
||||
|
||||
/// <summary>Gets the number of client connections the relay has accepted.</summary>
|
||||
public int AcceptedConnections => Volatile.Read(ref _accepted);
|
||||
|
||||
/// <summary>Starts (or resumes) relaying on <see cref="Port"/>.</summary>
|
||||
public void Restore()
|
||||
{
|
||||
_stop = new CancellationTokenSource();
|
||||
var listener = new TcpListener(IPAddress.Loopback, Port);
|
||||
listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
|
||||
listener.Start();
|
||||
_listener = listener;
|
||||
var token = _stop.Token;
|
||||
_ = Task.Run(() => AcceptLoopAsync(listener, token));
|
||||
}
|
||||
|
||||
/// <summary>Closes every relayed connection and refuses new ones until <see cref="Restore"/>.</summary>
|
||||
public void Cut()
|
||||
{
|
||||
try { _stop.Cancel(); } catch { }
|
||||
try { _listener?.Stop(); } catch { }
|
||||
_listener = null;
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var c in _live) { try { c.Close(); } catch { } }
|
||||
_live.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AcceptLoopAsync(TcpListener listener, CancellationToken token)
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
TcpClient downstream;
|
||||
try { downstream = await listener.AcceptTcpClientAsync(token); }
|
||||
catch { return; }
|
||||
Interlocked.Increment(ref _accepted);
|
||||
_ = Task.Run(() => RelayAsync(downstream, token));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RelayAsync(TcpClient downstream, CancellationToken token)
|
||||
{
|
||||
var upstream = new TcpClient(AddressFamily.InterNetwork);
|
||||
lock (_lock) { _live.Add(downstream); _live.Add(upstream); }
|
||||
try
|
||||
{
|
||||
var addr = (await Dns.GetHostAddressesAsync(_upstreamHost, token))
|
||||
.First(a => a.AddressFamily == AddressFamily.InterNetwork);
|
||||
await upstream.ConnectAsync(addr, _upstreamPort, token);
|
||||
var a = downstream.GetStream();
|
||||
var b = upstream.GetStream();
|
||||
await Task.WhenAny(a.CopyToAsync(b, token), b.CopyToAsync(a, token));
|
||||
}
|
||||
catch { /* cut or upstream gone */ }
|
||||
finally
|
||||
{
|
||||
try { downstream.Close(); } catch { }
|
||||
try { upstream.Close(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
Cut();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
+31
@@ -25,10 +25,41 @@ public sealed class ModbusArrayWriteSupportParityTests
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shapes where the driver has moved ahead of the server-side table (review 07 P6 / U3, Modbus lane) and
|
||||
/// <see cref="DriverArrayWriteSupport"/> — owned outside the Modbus lane — has not followed yet
|
||||
/// (follow-up F1): the tag factory now REFUSES a Coils tag with a non-Bool type (so the driver serves no
|
||||
/// such tag), and the driver now encodes String[] and BitInRegister[] holding-register writes.
|
||||
/// <see cref="Pending_F1_divergences_still_exist"/> fails the moment the table follows, so this list
|
||||
/// is removed with it rather than left to rot.
|
||||
/// </summary>
|
||||
private static bool PendingF1(ModbusRegion region, ModbusDataType dataType) =>
|
||||
(region == ModbusRegion.Coils && dataType != ModbusDataType.Bool)
|
||||
|| (region == ModbusRegion.HoldingRegisters && dataType is ModbusDataType.String or ModbusDataType.BitInRegister);
|
||||
|
||||
|
||||
public static TheoryData<ModbusRegion, ModbusDataType> PendingShapes()
|
||||
{
|
||||
var data = new TheoryData<ModbusRegion, ModbusDataType>();
|
||||
foreach (var (region, dt) in Shapes().Where(s => PendingF1(s.Region, s.DataType))) data.Add(region, dt);
|
||||
return data;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(PendingShapes))]
|
||||
public void Pending_F1_divergences_still_exist(ModbusRegion region, ModbusDataType dataType)
|
||||
{
|
||||
var json = $$"""{"region":"{{region}}","address":10,"dataType":"{{dataType}}","stringLength":4,"isArray":true,"arrayLength":2}""";
|
||||
var driverAccepts = region == ModbusRegion.HoldingRegisters; // String[] / BitInRegister[] encode; mistyped Coils are refused
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", json).ShouldNotBe(driverAccepts,
|
||||
"F1 landed — DriverArrayWriteSupport now agrees with the driver for this shape; delete it from PendingF1");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AllShapes))]
|
||||
public async Task Table_agrees_with_the_driver_write_path(ModbusRegion region, ModbusDataType dataType)
|
||||
{
|
||||
if (PendingF1(region, dataType)) return; // covered by Pending_F1_divergences_still_exist
|
||||
var json = $$"""{"region":"{{region}}","address":10,"dataType":"{{dataType}}","stringLength":4,"isArray":true,"arrayLength":2}""";
|
||||
var fake = new ModbusDriverTests.FakeTransport();
|
||||
var drv = new ModbusDriver(
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 U2: block-read coalescing is ON by default (gap 8), and it covers coils, discrete inputs,
|
||||
/// <c>BitInRegister</c> and <c>String</c> tags — 16 bit tags on one status word used to cost 16 reads of
|
||||
/// the same register, and 64 adjacent coils 64 FC01s.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusCoalescingDefaultsTests
|
||||
{
|
||||
private static async Task<(ModbusDriver, ScriptedTransport)> StartAsync(ModbusDriverOptions opts)
|
||||
{
|
||||
var t = new ScriptedTransport();
|
||||
var d = new ModbusDriver(opts, "m1", _ => t);
|
||||
await d.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
return (d, t);
|
||||
}
|
||||
|
||||
private static ModbusDriverOptions Opts(IEnumerable<ModbusTagDefinition> tags, bool disable = false) => new()
|
||||
{
|
||||
Host = "f", RawTags = ModbusRawTags.Entries(tags), DisableReadCoalescing = disable,
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Default_options_coalesce_with_a_gap_of_8()
|
||||
{
|
||||
new ModbusDriverOptions().MaxReadGap.ShouldBe((ushort)8);
|
||||
new ModbusDriverOptions().DisableReadCoalescing.ShouldBeFalse();
|
||||
ModbusDriverFactoryExtensions.ParseOptions("m1", """{"host":"h"}""").MaxReadGap.ShouldBe((ushort)8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Default_configured_holding_registers_are_read_in_one_PDU()
|
||||
{
|
||||
var tags = Enumerable.Range(0, 10).Select(i =>
|
||||
new ModbusTagDefinition($"HR{i}", ModbusRegion.HoldingRegisters, (ushort)(100 + i * 3), ModbusDataType.Int16)).ToArray();
|
||||
var (drv, t) = await StartAsync(Opts(tags));
|
||||
await using var _ = drv;
|
||||
for (var i = 0; i < 10; i++) t.Bank.HoldingRegisters[100 + i * 3] = (ushort)(i + 1);
|
||||
|
||||
var r = await drv.ReadAsync([.. tags.Select(x => x.Name)], TestContext.Current.CancellationToken);
|
||||
|
||||
t.Requests(0x03).Count.ShouldBe(1);
|
||||
r.Select(s => (short)s.Value!).ShouldBe(Enumerable.Range(1, 10).Select(i => (short)i));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix round 1 (coordinator ruling 2): an explicitly authored maxReadGap 0 — what the pre-#741 form
|
||||
/// persisted on every driver — keeps its old meaning, NO coalescing; the gap-8 default applies only to an
|
||||
/// absent key (positive control).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("""{"host":"h","maxReadGap":0,"disableFC23":false}""", 2)]
|
||||
[InlineData("""{"host":"h"}""", 1)]
|
||||
public async Task A_persisted_maxReadGap_0_keeps_coalescing_off(string legacyConfig, int expectedReads)
|
||||
{
|
||||
var parsed = ModbusDriverFactoryExtensions.ParseOptions("m1", legacyConfig);
|
||||
var tags = new[]
|
||||
{
|
||||
new ModbusTagDefinition("A", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16),
|
||||
new ModbusTagDefinition("B", ModbusRegion.HoldingRegisters, 101, ModbusDataType.Int16),
|
||||
};
|
||||
var (drv, t) = await StartAsync(new ModbusDriverOptions
|
||||
{
|
||||
Host = "f", MaxReadGap = parsed.MaxReadGap, DisableReadCoalescing = parsed.DisableReadCoalescing,
|
||||
RawTags = ModbusRawTags.Entries(tags), Probe = new ModbusProbeOptions { Enabled = false },
|
||||
});
|
||||
await using var _ = drv;
|
||||
await drv.ReadAsync(["A", "B"], TestContext.Current.CancellationToken);
|
||||
t.Requests(0x03).Count.ShouldBe(expectedReads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisableReadCoalescing_reads_every_tag_alone()
|
||||
{
|
||||
// Positive control for the default-on test: the off switch really is per-tag.
|
||||
var tags = new[]
|
||||
{
|
||||
new ModbusTagDefinition("A", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16),
|
||||
new ModbusTagDefinition("B", ModbusRegion.HoldingRegisters, 101, ModbusDataType.Int16),
|
||||
};
|
||||
var (drv, t) = await StartAsync(Opts(tags, disable: true));
|
||||
await using var _ = drv;
|
||||
await drv.ReadAsync(["A", "B"], TestContext.Current.CancellationToken);
|
||||
t.Requests(0x03).Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ModbusRegion.Coils, (byte)0x01)]
|
||||
[InlineData(ModbusRegion.DiscreteInputs, (byte)0x02)]
|
||||
public async Task Adjacent_bit_tags_fold_into_one_bit_read(ModbusRegion region, byte fc)
|
||||
{
|
||||
var tags = Enumerable.Range(0, 64).Select(i =>
|
||||
new ModbusTagDefinition($"B{i}", region, (ushort)(10 + i), ModbusDataType.Bool)).ToArray();
|
||||
var (drv, t) = await StartAsync(Opts(tags));
|
||||
await using var _ = drv;
|
||||
var bank = region == ModbusRegion.Coils ? t.Bank.Coils : t.Bank.DiscreteInputs;
|
||||
for (var i = 0; i < 64; i++) bank[10 + i] = i % 3 == 0;
|
||||
|
||||
var r = await drv.ReadAsync([.. tags.Select(x => x.Name)], TestContext.Current.CancellationToken);
|
||||
|
||||
t.Requests(fc).ShouldBe([(fc, (ushort)10, (ushort)64)]);
|
||||
for (var i = 0; i < 64; i++) r[i].Value.ShouldBe(i % 3 == 0, $"bit {i}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_tags_on_one_status_word_fold_into_one_register_read()
|
||||
{
|
||||
var tags = Enumerable.Range(0, 16).Select(b =>
|
||||
new ModbusTagDefinition($"S{b}", ModbusRegion.HoldingRegisters, 40, ModbusDataType.BitInRegister, BitIndex: (byte)b)).ToArray();
|
||||
var (drv, t) = await StartAsync(Opts(tags));
|
||||
await using var _ = drv;
|
||||
t.Bank.HoldingRegisters[40] = 0b1010_0000_0000_0101;
|
||||
|
||||
var r = await drv.ReadAsync([.. tags.Select(x => x.Name)], TestContext.Current.CancellationToken);
|
||||
|
||||
t.Requests(0x03).ShouldBe([((byte)0x03, (ushort)40, (ushort)1)]);
|
||||
for (var b = 0; b < 16; b++) r[b].Value.ShouldBe(((0b1010_0000_0000_0101 >> b) & 1) == 1, $"bit {b}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_string_tag_coalesces_with_its_neighbours()
|
||||
{
|
||||
var tags = new[]
|
||||
{
|
||||
new ModbusTagDefinition("Num", ModbusRegion.HoldingRegisters, 50, ModbusDataType.Int16),
|
||||
new ModbusTagDefinition("Str", ModbusRegion.HoldingRegisters, 51, ModbusDataType.String, StringLength: 4),
|
||||
new ModbusTagDefinition("After", ModbusRegion.HoldingRegisters, 53, ModbusDataType.UInt16),
|
||||
};
|
||||
var (drv, t) = await StartAsync(Opts(tags));
|
||||
await using var _ = drv;
|
||||
t.Bank.HoldingRegisters[50] = 7;
|
||||
t.Bank.HoldingRegisters[51] = ('A' << 8) | 'B';
|
||||
t.Bank.HoldingRegisters[52] = ('C' << 8) | 'D';
|
||||
t.Bank.HoldingRegisters[53] = 9;
|
||||
|
||||
var r = await drv.ReadAsync(["Num", "Str", "After"], TestContext.Current.CancellationToken);
|
||||
|
||||
t.Requests(0x03).ShouldBe([((byte)0x03, (ushort)50, (ushort)4)]);
|
||||
r[0].Value.ShouldBe((short)7);
|
||||
r[1].Value.ShouldBe("ABCD");
|
||||
r[2].Value.ShouldBe((ushort)9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refused_coil_range_is_prohibited_and_reprobed_with_FC01()
|
||||
{
|
||||
var tags = Enumerable.Range(0, 4).Select(i =>
|
||||
new ModbusTagDefinition($"C{i}", ModbusRegion.Coils, (ushort)(20 + i), ModbusDataType.Bool)).ToArray();
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = "f", RawTags = ModbusRawTags.Entries(tags), Probe = new ModbusProbeOptions { Enabled = false },
|
||||
AutoProhibitReprobeInterval = TimeSpan.FromHours(1),
|
||||
};
|
||||
var (drv, t) = await StartAsync(opts);
|
||||
await using var _ = drv;
|
||||
var refuseMerged = true;
|
||||
t.Handler = (_, pdu, _) =>
|
||||
{
|
||||
var qty = (pdu[3] << 8) | pdu[4];
|
||||
return pdu[0] == 0x01 && qty > 1 && refuseMerged
|
||||
? Task.FromException<byte[]>(new ModbusException(0x01, 0x02, "protected"))
|
||||
: null;
|
||||
};
|
||||
|
||||
var r = await drv.ReadAsync([.. tags.Select(x => x.Name)], TestContext.Current.CancellationToken);
|
||||
r.ShouldAllBe(s => s.StatusCode == 0u, "the per-tag fallback still reads every coil");
|
||||
drv.AutoProhibitedRangeCount.ShouldBe(1);
|
||||
|
||||
refuseMerged = false;
|
||||
while (t.Sent.TryDequeue(out var _1)) { }
|
||||
await drv.RunReprobeOnceForTestAsync(TestContext.Current.CancellationToken);
|
||||
t.Sent.ShouldAllBe(s => s.Pdu[0] == 0x01, "a coil range is re-probed with FC01, never a register read");
|
||||
drv.AutoProhibitedRangeCount.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
@@ -42,9 +42,9 @@ public sealed class ModbusCoalescingTests
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that MaxReadGap=0 defaults to per-tag reads without coalescing.</summary>
|
||||
/// <summary>MaxReadGap=0 means no coalescing (its historical meaning, kept for persisted configs).</summary>
|
||||
[Fact]
|
||||
public async Task MaxReadGap_Zero_Defaults_To_Per_Tag_Reads()
|
||||
public async Task MaxReadGap_Zero_Means_No_Coalescing()
|
||||
{
|
||||
var fake = new CountingTransport();
|
||||
var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
|
||||
@@ -56,7 +56,7 @@ public sealed class ModbusCoalescingTests
|
||||
|
||||
await drv.ReadAsync(["T1", "T2"], CancellationToken.None);
|
||||
|
||||
// With coalescing off, expect 2 separate FC03 reads.
|
||||
// MaxReadGap=0 is coalescing OFF: expect 2 separate FC03 reads.
|
||||
var fc03Reads = fake.Reads.Where(r => r.Fc == 0x03).ToList();
|
||||
fc03Reads.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 H2: the deadband baseline is keyed on (value, status). A Good-after-Bad recovery carrying
|
||||
/// the IDENTICAL value must publish — the value-only dedup left the node Bad until the value drifted.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusDeadbandRecoveryTests
|
||||
{
|
||||
private const uint Good = 0u;
|
||||
private const uint Uncertain = 0x40000000u;
|
||||
private const uint BadComm = 0x80050000u;
|
||||
|
||||
private static async Task<ModbusDriver> NewDriverAsync()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16, Deadband: 5.0);
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = "f", RawTags = ModbusRawTags.Entries([tag]), Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
var drv = new ModbusDriver(opts, "m1", _ => new ModbusDriverTests.FakeTransport());
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
return drv;
|
||||
}
|
||||
|
||||
private static DataValueSnapshot Snap(object? v, uint status) => new(v, status, DateTime.UtcNow, DateTime.UtcNow);
|
||||
|
||||
[Fact]
|
||||
public async Task Good_after_Bad_with_the_same_value_publishes()
|
||||
{
|
||||
await using var drv = await NewDriverAsync();
|
||||
drv.ShouldPublish("Temp", Snap((short)10, Good)).ShouldBeTrue(); // baseline
|
||||
drv.ShouldPublish("Temp", Snap(null, BadComm)).ShouldBeTrue(); // comms drop
|
||||
drv.ShouldPublish("Temp", Snap((short)10, Good)).ShouldBeTrue("the recovery publish must not be swallowed");
|
||||
// Positive control: the deadband is still active after the recovery re-seeded the baseline.
|
||||
drv.ShouldPublish("Temp", Snap((short)11, Good)).ShouldBeFalse();
|
||||
drv.ShouldPublish("Temp", Snap((short)16, Good)).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_status_change_with_the_same_value_publishes()
|
||||
{
|
||||
await using var drv = await NewDriverAsync();
|
||||
drv.ShouldPublish("Temp", Snap((short)10, Good)).ShouldBeTrue();
|
||||
drv.ShouldPublish("Temp", Snap((short)10, Uncertain)).ShouldBeTrue("a quality change must publish");
|
||||
drv.ShouldPublish("Temp", Snap((short)12, Uncertain)).ShouldBeFalse("within deadband at the same quality");
|
||||
drv.ShouldPublish("Temp", Snap((short)10, Good)).ShouldBeTrue("back to Good must publish");
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ public sealed class ModbusDriverTests
|
||||
0x06 => Task.FromResult(WriteSingleReg(pdu)),
|
||||
0x0F => Task.FromResult(WriteMultipleCoils(pdu)),
|
||||
0x10 => Task.FromResult(WriteMultipleRegs(pdu)),
|
||||
0x17 => Task.FromResult(ReadWriteMultipleRegs(pdu)),
|
||||
_ => Task.FromException<byte[]>(new ModbusException(fc, 0x01, $"fc={fc} not supported by fake")),
|
||||
};
|
||||
}
|
||||
@@ -102,6 +103,26 @@ public sealed class ModbusDriverTests
|
||||
return new byte[] { 0x10, pdu[1], pdu[2], pdu[3], pdu[4] };
|
||||
}
|
||||
|
||||
// FC23 Read/Write Multiple Registers: write first, then read back the read range.
|
||||
private byte[] ReadWriteMultipleRegs(byte[] pdu)
|
||||
{
|
||||
var readAddr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
var readQty = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
var writeAddr = (ushort)((pdu[5] << 8) | pdu[6]);
|
||||
var writeQty = (ushort)((pdu[7] << 8) | pdu[8]);
|
||||
for (var i = 0; i < writeQty; i++)
|
||||
HoldingRegisters[writeAddr + i] = (ushort)((pdu[10 + i * 2] << 8) | pdu[11 + i * 2]);
|
||||
var resp = new byte[2 + readQty * 2];
|
||||
resp[0] = 0x17;
|
||||
resp[1] = (byte)(readQty * 2);
|
||||
for (var i = 0; i < readQty; i++)
|
||||
{
|
||||
resp[2 + i * 2] = (byte)(HoldingRegisters[readAddr + i] >> 8);
|
||||
resp[3 + i * 2] = (byte)(HoldingRegisters[readAddr + i] & 0xFF);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
private byte[] WriteMultipleCoils(byte[] pdu)
|
||||
{
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 U1: <c>Family</c>, <c>MelsecSubFamily</c> and <c>DisableFC23</c> were authored in the
|
||||
/// AdminUI and consumed by nothing. A tag's <c>addressString</c> now resolves through
|
||||
/// <see cref="ModbusAddressParser"/> with the driver's family; <c>DisableFC23</c> gates the FC23
|
||||
/// write-and-read-back path for multi-register writes. Also pins that the AdminUI form's own
|
||||
/// (TimeSpan-shaped) config keys reach the driver.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusFamilyWiringTests
|
||||
{
|
||||
// ---------------- addressString through the family-aware parser ----------------
|
||||
|
||||
[Fact]
|
||||
public void A_MELSEC_D_register_resolves_only_under_the_MELSEC_family()
|
||||
{
|
||||
const string json = """{"addressString":"D100"}""";
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
|
||||
def.Region.ShouldBe(ModbusRegion.HoldingRegisters);
|
||||
def.Address.ShouldBe((ushort)100);
|
||||
// Positive control: the same text means nothing to a Generic (Modicon) driver.
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_DL205_V_memory_address_resolves_octal_under_DL205()
|
||||
{
|
||||
ModbusTagDefinitionFactory.FromTagConfig("""{"addressString":"V2000"}""", "T", ModbusFamily.DL205, MelsecFamily.Q_L_iQR, out var def)
|
||||
.ShouldBeTrue();
|
||||
def.Region.ShouldBe(ModbusRegion.HoldingRegisters);
|
||||
def.Address.ShouldBe((ushort)0x400); // V2000 octal = 1024
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_string_only_blob_carries_type_order_and_count_and_keeps_the_other_keys()
|
||||
{
|
||||
ModbusTagDefinitionFactory.FromTagConfig(
|
||||
"""{"addressString":"40011:F:CDAB:4","writable":false,"unitId":3}""",
|
||||
"T", ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
|
||||
def.Region.ShouldBe(ModbusRegion.HoldingRegisters);
|
||||
def.Address.ShouldBe((ushort)10);
|
||||
def.DataType.ShouldBe(ModbusDataType.Float32);
|
||||
def.ByteOrder.ShouldBe(ModbusByteOrder.WordSwap);
|
||||
def.ArrayCount.ShouldBe(4);
|
||||
def.Writable.ShouldBeFalse();
|
||||
def.UnitId.ShouldBe((byte)3);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix round 1 (review m2): persisted region/address are authoritative over an addressString, so a
|
||||
/// later Family / MelsecSubFamily change can never silently remap a live tag the editor resolved. (Since
|
||||
/// fix round 3 the keys win by PRESENCE; the marker in this blob is display state only.)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Persisted_resolved_keys_win_over_the_address_string_so_a_family_change_cannot_remap()
|
||||
{
|
||||
// The editor resolved D100 under MELSEC, persisted HR 100 and MARKED it; the driver is later switched.
|
||||
const string json = """{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100,"dataType":"Int16"}""";
|
||||
foreach (var family in new[] { ModbusFamily.MELSEC, ModbusFamily.DL205, ModbusFamily.Generic })
|
||||
{
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", family, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
|
||||
def.Address.ShouldBe((ushort)100, $"family {family}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix round 3 (review r2 N2): the persisted-shape matrix. Every shape that carries region + address loads
|
||||
/// from them under every family — including the pre-#741 "keys + addressString, no marker" row, which the
|
||||
/// old driver served from its keys and must keep serving so. Only a string-only blob (no keys) resolves
|
||||
/// the string.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("keys only", """{"region":"InputRegisters","address":5}""", ModbusRegion.InputRegisters, 5)]
|
||||
[InlineData("pre-lane keys + string", """{"region":"InputRegisters","address":5,"dataType":"Int16","addressString":"40011:F"}""", ModbusRegion.InputRegisters, 5)]
|
||||
[InlineData("pre-lane stamped HR/0 + string", """{"region":"HoldingRegisters","address":0,"dataType":"Int16","addressString":"D100"}""", ModbusRegion.HoldingRegisters, 0)]
|
||||
[InlineData("string only", """{"addressString":"40011:F"}""", ModbusRegion.HoldingRegisters, 10)]
|
||||
[InlineData("string + marker + keys (editor-pinned)", """{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100}""", ModbusRegion.HoldingRegisters, 100)]
|
||||
[InlineData("string + marker, no keys", """{"addressString":"40011:F","addressResolved":true}""", ModbusRegion.HoldingRegisters, 10)]
|
||||
public void Every_persisted_shape_resolves_to_its_address_under_every_family(
|
||||
string shape, string json, ModbusRegion region, int address)
|
||||
{
|
||||
foreach (var (family, sub) in new[]
|
||||
{
|
||||
(ModbusFamily.Generic, MelsecFamily.Q_L_iQR), (ModbusFamily.DL205, MelsecFamily.Q_L_iQR),
|
||||
(ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR), (ModbusFamily.MELSEC, MelsecFamily.F_iQF),
|
||||
})
|
||||
{
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", family, sub, out var def).ShouldBeTrue($"{shape} / {family}");
|
||||
def.Region.ShouldBe(region, $"{shape} / {family}");
|
||||
def.Address.ShouldBe((ushort)address, $"{shape} / {family}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_string_only_blob_no_family_parses_is_refused_not_served_as_HR0()
|
||||
{
|
||||
ModbusTagDefinitionFactory.FromTagConfig("""{"addressString":"D100"}""", "T", ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out _)
|
||||
.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unparseable_address_string_rejects_the_tag_and_the_deploy_gate_says_why()
|
||||
{
|
||||
const string json = """{"addressString":"ZZ9"}""";
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", out _).ShouldBeFalse();
|
||||
ModbusTagDefinitionFactory.Inspect(json).ShouldContain(w => w.Contains("addressString"));
|
||||
// The family-unaware gate accepts what SOME family resolves (it must never be stricter than the driver).
|
||||
ModbusTagDefinitionFactory.Inspect("""{"addressString":"D100"}""").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_driver_resolves_address_strings_with_its_configured_family()
|
||||
{
|
||||
var fake = new ScriptedTransport();
|
||||
fake.Bank.HoldingRegisters[100] = 1234;
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = "f", Family = ModbusFamily.MELSEC, Probe = new ModbusProbeOptions { Enabled = false },
|
||||
RawTags = [new RawTagEntry("D100", """{"addressString":"D100"}""", false)],
|
||||
};
|
||||
await using var drv = new ModbusDriver(opts, "m1", _ => fake);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
var r = await drv.ReadAsync(["D100"], TestContext.Current.CancellationToken);
|
||||
r[0].StatusCode.ShouldBe(0u);
|
||||
r[0].Value.ShouldBe((short)1234);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Family_and_MelsecSubFamily_parse_from_the_driver_config()
|
||||
{
|
||||
var o = ModbusDriverFactoryExtensions.ParseOptions("m1",
|
||||
"""{"host":"h","family":"MELSEC","melsecSubFamily":"F_iQF","disableFC23":true,"useFC23ForMultiRegisterWrites":true}""");
|
||||
o.Family.ShouldBe(ModbusFamily.MELSEC);
|
||||
o.MelsecSubFamily.ShouldBe(MelsecFamily.F_iQF);
|
||||
o.DisableFC23.ShouldBeTrue();
|
||||
o.UseFC23ForMultiRegisterWrites.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---------------- FC23 is OPT-IN; DisableFC23 is the kill-switch ----------------
|
||||
|
||||
private static async Task<(ModbusDriver, ScriptedTransport)> StartWriteAsync(string driverConfigJson)
|
||||
{
|
||||
var t = new ScriptedTransport();
|
||||
var opts = ModbusDriverFactoryExtensions.ParseOptions("m1", driverConfigJson) is var parsed
|
||||
? new ModbusDriverOptions
|
||||
{
|
||||
Host = "f", DisableFC23 = parsed.DisableFC23,
|
||||
UseFC23ForMultiRegisterWrites = parsed.UseFC23ForMultiRegisterWrites,
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
RawTags = ModbusRawTags.Entries([new ModbusTagDefinition("F", ModbusRegion.HoldingRegisters, 20, ModbusDataType.Float32)]),
|
||||
}
|
||||
: null!;
|
||||
var drv = new ModbusDriver(opts, "m1", _ => t);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
return (drv, t);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix round 1 (coordinator ruling 1): a config without the opt-in key keeps the FC16 wire — including the
|
||||
/// legacy shape every form-saved config carries ("disableFC23": false).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("""{"host":"h"}""")]
|
||||
[InlineData("""{"host":"h","disableFC23":false,"maxReadGap":0,"useFC15ForSingleCoilWrites":false}""")]
|
||||
public async Task Without_the_opt_in_multi_register_writes_stay_on_FC16(string legacyConfig)
|
||||
{
|
||||
var (drv, t) = await StartWriteAsync(legacyConfig);
|
||||
await using var _ = drv;
|
||||
(await drv.WriteAsync([new WriteRequest("F", 1.5f)], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u);
|
||||
t.Sent.Select(s => s.Pdu[0]).ShouldBe([(byte)0x10]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Opted_in_multi_register_writes_use_FC23_write_and_read_back()
|
||||
{
|
||||
var (drv, t) = await StartWriteAsync("""{"host":"h","useFC23ForMultiRegisterWrites":true}""");
|
||||
await using var _ = drv;
|
||||
var w = await drv.WriteAsync([new WriteRequest("F", 1.5f)], TestContext.Current.CancellationToken);
|
||||
w[0].StatusCode.ShouldBe(0u);
|
||||
t.Sent.Select(s => s.Pdu[0]).ShouldBe([(byte)0x17]);
|
||||
(await drv.ReadAsync(["F"], TestContext.Current.CancellationToken))[0].Value.ShouldBe(1.5f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisableFC23_overrides_the_opt_in()
|
||||
{
|
||||
var (drv, t) = await StartWriteAsync("""{"host":"h","useFC23ForMultiRegisterWrites":true,"disableFC23":true}""");
|
||||
await using var _ = drv;
|
||||
(await drv.WriteAsync([new WriteRequest("F", 1.5f)], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u);
|
||||
t.Sent.Select(s => s.Pdu[0]).ShouldBe([(byte)0x10]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_opted_in_device_without_FC23_falls_back_to_FC16_once_and_stays_on_it()
|
||||
{
|
||||
var (drv, t) = await StartWriteAsync("""{"host":"h","useFC23ForMultiRegisterWrites":true}""");
|
||||
await using var _ = drv;
|
||||
t.Handler = (_, pdu, _) => pdu[0] == 0x17
|
||||
? Task.FromException<byte[]>(new ModbusException(0x17, 0x01, "illegal function"))
|
||||
: null;
|
||||
|
||||
(await drv.WriteAsync([new WriteRequest("F", 1.5f)], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u);
|
||||
(await drv.WriteAsync([new WriteRequest("F", 2.5f)], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u);
|
||||
|
||||
t.Sent.Select(s => s.Pdu[0]).ShouldBe([(byte)0x17, (byte)0x10, (byte)0x10]);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
// ---------------- the AdminUI form's own keys reach the driver ----------------
|
||||
|
||||
[Fact]
|
||||
public void The_AdminUI_form_shape_of_the_timing_keys_is_honoured()
|
||||
{
|
||||
// The form serialises ModbusDriverOptions itself (camelCase, TimeSpan strings), not the *Ms DTO keys.
|
||||
var formShape = JsonSerializer.Serialize(new ModbusDriverOptions
|
||||
{
|
||||
Host = "h",
|
||||
Timeout = TimeSpan.FromSeconds(7),
|
||||
IdleDisconnectTimeout = TimeSpan.FromSeconds(90),
|
||||
AutoProhibitReprobeInterval = TimeSpan.FromSeconds(45),
|
||||
Probe = new ModbusProbeOptions { Interval = TimeSpan.FromSeconds(11), Timeout = TimeSpan.FromSeconds(3) },
|
||||
KeepAlive = new ModbusKeepAliveOptions { Time = TimeSpan.FromSeconds(60), Interval = TimeSpan.FromSeconds(20) },
|
||||
Reconnect = new ModbusReconnectOptions { InitialDelay = TimeSpan.FromSeconds(2), MaxDelay = TimeSpan.FromSeconds(40) },
|
||||
}, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Converters = { new JsonStringEnumConverter() } });
|
||||
|
||||
var o = ModbusDriverFactoryExtensions.ParseOptions("m1", formShape);
|
||||
|
||||
o.Timeout.ShouldBe(TimeSpan.FromSeconds(7));
|
||||
o.IdleDisconnectTimeout.ShouldBe(TimeSpan.FromSeconds(90));
|
||||
o.AutoProhibitReprobeInterval.ShouldBe(TimeSpan.FromSeconds(45));
|
||||
o.Probe.Interval.ShouldBe(TimeSpan.FromSeconds(11));
|
||||
o.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(3));
|
||||
o.KeepAlive.Time.ShouldBe(TimeSpan.FromSeconds(60));
|
||||
o.KeepAlive.Interval.ShouldBe(TimeSpan.FromSeconds(20));
|
||||
o.Reconnect.InitialDelay.ShouldBe(TimeSpan.FromSeconds(2));
|
||||
o.Reconnect.MaxDelay.ShouldBe(TimeSpan.FromSeconds(40));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_millisecond_keys_still_win_when_both_are_present()
|
||||
{
|
||||
var o = ModbusDriverFactoryExtensions.ParseOptions("m1", """{"host":"h","timeoutMs":1500,"timeout":"00:00:07"}""");
|
||||
o.Timeout.ShouldBe(TimeSpan.FromMilliseconds(1500));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The driver health contract (review 07 H4 + H7, and the lifecycle/fail-fast rules every driver lane
|
||||
/// must meet): a device REFUSAL is per-tag Bad with no Degraded and proves the device answered; a comms
|
||||
/// fault degrades and preserves LastSuccessfulRead; health is one write per batch; a comms timeout on
|
||||
/// one unit fails the rest of that unit's batch fast; writes map transport failures to
|
||||
/// BadCommunicationError and value problems to BadOutOfRange / BadTypeMismatch.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusHealthContractTests
|
||||
{
|
||||
private const uint Good = 0u;
|
||||
private const uint BadOutOfRange = 0x803C0000u;
|
||||
private const uint BadDeviceFailure = 0x808B0000u;
|
||||
private const uint BadCommunicationError = 0x80050000u;
|
||||
private const uint BadTypeMismatch = 0x80740000u;
|
||||
private const uint BadNodeIdUnknown = 0x80340000u;
|
||||
|
||||
private static ModbusDriverOptions Opts(params ModbusTagDefinition[] tags) => new()
|
||||
{
|
||||
Host = "f", RawTags = ModbusRawTags.Entries(tags), Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
|
||||
private static ModbusTagDefinition Hr(string name, ushort addr, byte? unit = null, ModbusDataType type = ModbusDataType.Int16) =>
|
||||
new(name, ModbusRegion.HoldingRegisters, addr, type, UnitId: unit);
|
||||
|
||||
private static Task<byte[]> Refuse(byte code, byte[] pdu) =>
|
||||
Task.FromException<byte[]>(new ModbusException(pdu[0], code, $"exception {code}"));
|
||||
|
||||
private static ushort Addr(byte[] pdu) => (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
|
||||
private static async Task<(ModbusDriver Driver, ScriptedTransport Transport)> StartAsync(
|
||||
ModbusDriverOptions opts, ILogger<ModbusDriver>? logger = null)
|
||||
{
|
||||
var t = new ScriptedTransport();
|
||||
var d = new ModbusDriver(opts, "m1", _ => t, logger);
|
||||
await d.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
return (d, t);
|
||||
}
|
||||
|
||||
// ---------------- H4: refusals ----------------
|
||||
|
||||
[Fact]
|
||||
public async Task All_tags_refused_keeps_Healthy_and_advances_LastSuccessfulRead()
|
||||
{
|
||||
var (drv, t) = await StartAsync(Opts(Hr("A", 10), Hr("B", 11)));
|
||||
await using var _ = drv;
|
||||
t.Handler = (_, pdu, _) => Refuse(0x02, pdu);
|
||||
var before = drv.GetHealth().LastSuccessfulRead;
|
||||
|
||||
var r = await drv.ReadAsync(["A", "B"], TestContext.Current.CancellationToken);
|
||||
|
||||
r.ShouldAllBe(s => s.StatusCode == BadOutOfRange);
|
||||
var h = drv.GetHealth();
|
||||
h.State.ShouldBe(DriverState.Healthy, "a refusal means the device answered — no Degraded, no watchdog fodder");
|
||||
h.LastSuccessfulRead.ShouldNotBeNull();
|
||||
h.LastSuccessfulRead!.Value.ShouldBeGreaterThanOrEqualTo(before!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refusal_after_a_comms_fault_restores_Healthy()
|
||||
{
|
||||
var (drv, t) = await StartAsync(Opts(Hr("A", 10)));
|
||||
await using var _ = drv;
|
||||
t.Handler = (_, _, _) => Task.FromException<byte[]>(new IOException("socket dropped"));
|
||||
await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
|
||||
var degraded = drv.GetHealth();
|
||||
degraded.State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
t.Handler = (_, pdu, _) => Refuse(0x02, pdu);
|
||||
await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
|
||||
var h = drv.GetHealth();
|
||||
h.State.ShouldBe(DriverState.Healthy);
|
||||
h.LastSuccessfulRead!.Value.ShouldBeGreaterThanOrEqualTo(degraded.LastSuccessfulRead!.Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x04)]
|
||||
[InlineData(0x0B)]
|
||||
public async Task Non_refusal_exception_codes_still_degrade(byte code)
|
||||
{
|
||||
// Positive control for the refusal tests: a server-failure / gateway code is NOT a refusal.
|
||||
var (drv, t) = await StartAsync(Opts(Hr("A", 10)));
|
||||
await using var _ = drv;
|
||||
t.Handler = (_, pdu, _) => Refuse(code, pdu);
|
||||
await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Mixed_refused_and_good_batch_is_Healthy_regardless_of_tag_order(bool refusedFirst)
|
||||
{
|
||||
var (drv, t) = await StartAsync(Opts(Hr("Refused", 10), Hr("Fine", 50)));
|
||||
await using var _ = drv;
|
||||
t.Handler = (_, pdu, _) => Addr(pdu) == 10 ? Refuse(0x02, pdu) : null;
|
||||
string[] refs = refusedFirst ? ["Refused", "Fine"] : ["Fine", "Refused"];
|
||||
var r = await drv.ReadAsync(refs, TestContext.Current.CancellationToken);
|
||||
r.Count(s => s.StatusCode == Good).ShouldBe(1);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refused_tag_warns_once_not_once_per_poll()
|
||||
{
|
||||
var logger = new ListLogger();
|
||||
var (drv, t) = await StartAsync(Opts(Hr("A", 10)), logger);
|
||||
await using var _ = drv;
|
||||
t.Handler = (_, pdu, _) => Refuse(0x02, pdu);
|
||||
for (var i = 0; i < 3; i++) await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
|
||||
logger.Warnings.Count(m => m.Contains("refused") && m.Contains("A")).ShouldBe(1);
|
||||
}
|
||||
|
||||
// ---------------- fail fast per unit / endpoint ----------------
|
||||
|
||||
[Fact]
|
||||
public async Task A_comms_timeout_on_one_unit_fails_the_rest_of_that_units_batch_fast()
|
||||
{
|
||||
var (drv, t) = await StartAsync(Opts(Hr("U2a", 10, 2), Hr("U2b", 11, 2), Hr("U2c", 12, 2), Hr("U1", 13, 1)));
|
||||
await using var _ = drv;
|
||||
t.Bank.HoldingRegisters[13] = 42;
|
||||
t.Handler = (unit, _, _) => unit == 2
|
||||
? Task.FromException<byte[]>(new ModbusTransportDesyncException(
|
||||
ModbusTransportDesyncException.DesyncReason.Timeout, "timeout"))
|
||||
: null;
|
||||
|
||||
var r = await drv.ReadAsync(["U2a", "U2b", "U2c", "U1"], TestContext.Current.CancellationToken);
|
||||
|
||||
t.SendsTo(2).ShouldBe(1, "one timeout per unit per batch, not one per tag");
|
||||
r.Take(3).ShouldAllBe(s => s.StatusCode == BadCommunicationError);
|
||||
// Positive control: the healthy unit in the same batch is still read.
|
||||
r[3].StatusCode.ShouldBe(Good);
|
||||
r[3].Value.ShouldBe((short)42);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unreachable_endpoint_fails_the_whole_batch_fast()
|
||||
{
|
||||
var (drv, t) = await StartAsync(Opts(Hr("A", 10, 1), Hr("B", 11, 2), Hr("C", 12, 3)));
|
||||
await using var _ = drv;
|
||||
t.Handler = (_, _, _) => Task.FromException<byte[]>(new ModbusTransportUnavailableException("throttled"));
|
||||
var r = await drv.ReadAsync(["A", "B", "C"], TestContext.Current.CancellationToken);
|
||||
t.TotalSends.ShouldBe(1);
|
||||
r.ShouldAllBe(s => s.StatusCode == BadCommunicationError);
|
||||
}
|
||||
|
||||
// ---------------- H7: write status mapping + write-side health ----------------
|
||||
|
||||
[Fact]
|
||||
public async Task Write_transport_failure_is_BadCommunicationError_and_a_good_read_clears_it()
|
||||
{
|
||||
// Fix round 1 (review Important #3): ONE health writer for reads and writes. A single write blip used
|
||||
// to latch Degraded until the next good WRITE — forever, in a read-mostly plant.
|
||||
var (drv, t) = await StartAsync(Opts(Hr("A", 10)));
|
||||
await using var _ = drv;
|
||||
t.Handler = (_, pdu, _) => pdu[0] == 0x06 ? Task.FromException<byte[]>(new IOException("dropped")) : null;
|
||||
|
||||
var w = await drv.WriteAsync([new WriteRequest("A", (short)1)], TestContext.Current.CancellationToken);
|
||||
w[0].StatusCode.ShouldBe(BadCommunicationError);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
(await drv.ReadAsync(["A"], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(Good);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a good read proves the shared transport answered");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_good_write_clears_a_read_side_comms_fault()
|
||||
{
|
||||
var (drv, t) = await StartAsync(Opts(Hr("A", 10)));
|
||||
await using var _ = drv;
|
||||
t.Handler = (_, pdu, _) => pdu[0] == 0x03 ? Task.FromException<byte[]>(new IOException("dropped")) : null;
|
||||
await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
(await drv.WriteAsync([new WriteRequest("A", (short)2)], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(Good);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_malformed_reply_does_not_fail_the_rest_of_the_unit_fast()
|
||||
{
|
||||
// Review m3: a byte-count mismatch is a reply that ARRIVED — only a timeout / desync / IO fails the unit fast.
|
||||
var (drv, t) = await StartAsync(Opts(Hr("Bad", 10), Hr("Fine", 50)) is var o
|
||||
? new ModbusDriverOptions { Host = o.Host, RawTags = o.RawTags, Probe = o.Probe, DisableReadCoalescing = true }
|
||||
: null!);
|
||||
await using var _ = drv;
|
||||
t.Bank.HoldingRegisters[50] = 9;
|
||||
t.Handler = (_, pdu, _) => Addr(pdu) == 10 ? Task.FromResult(new byte[] { 0x03, 0x04, 0, 0 }) : null;
|
||||
|
||||
var r = await drv.ReadAsync(["Bad", "Fine"], TestContext.Current.CancellationToken);
|
||||
r[0].StatusCode.ShouldBe(BadCommunicationError);
|
||||
r[1].Value.ShouldBe((short)9, "the sibling on the same unit is still read");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsync_leaves_health_Unknown()
|
||||
{
|
||||
// Review m1: Dispose bumps the generation (dropping a concurrent shutdown's write), so it must write its own.
|
||||
var (drv, _) = await StartAsync(Opts(Hr("A", 10)));
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
await drv.DisposeAsync();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_write_refusal_counts_as_answered_and_clears_a_write_side_fault()
|
||||
{
|
||||
var (drv, t) = await StartAsync(Opts(Hr("A", 10)));
|
||||
await using var _ = drv;
|
||||
t.Handler = (_, pdu, _) => pdu[0] == 0x06 ? Task.FromException<byte[]>(new IOException("dropped")) : null;
|
||||
await drv.WriteAsync([new WriteRequest("A", (short)1)], TestContext.Current.CancellationToken);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
t.Handler = (_, pdu, _) => pdu[0] == 0x06 ? Refuse(0x03, pdu) : null;
|
||||
var w = await drv.WriteAsync([new WriteRequest("A", (short)1)], TestContext.Current.CancellationToken);
|
||||
w[0].StatusCode.ShouldBe(BadOutOfRange);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_value_problems_map_to_OutOfRange_and_TypeMismatch_without_touching_health()
|
||||
{
|
||||
var arr = new ModbusTagDefinition("Arr", ModbusRegion.HoldingRegisters, 20, ModbusDataType.Int16, ArrayCount: 3);
|
||||
var (drv, t) = await StartAsync(Opts(Hr("Bcd", 10, type: ModbusDataType.Bcd16), Hr("I16", 11), arr));
|
||||
await using var _ = drv;
|
||||
|
||||
var w = await drv.WriteAsync([
|
||||
new WriteRequest("Bcd", 12345), // > 4 BCD digits
|
||||
new WriteRequest("I16", 70000), // > Int16
|
||||
new WriteRequest("I16", "not a number"),
|
||||
new WriteRequest("Arr", new short[] { 1, 2 }), // length mismatch
|
||||
new WriteRequest("Arr", new int[] { 1, 2, 3 }), // CLR list of another element type converts (ScadaBridge shape)
|
||||
], TestContext.Current.CancellationToken);
|
||||
|
||||
w[0].StatusCode.ShouldBe(BadOutOfRange);
|
||||
w[1].StatusCode.ShouldBe(BadOutOfRange);
|
||||
w[2].StatusCode.ShouldBe(BadTypeMismatch);
|
||||
w[3].StatusCode.ShouldBe(BadOutOfRange);
|
||||
w[4].StatusCode.ShouldBe(Good);
|
||||
t.Bank.HoldingRegisters[22].ShouldBe((ushort)3);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failed_read_invalidates_the_WriteOnChangeOnly_cache()
|
||||
{
|
||||
// Value-only suppression is the write-side twin of the #527 dedup trap: after a comms fault the
|
||||
// device's register is unknown (it may have rebooted), so re-asserting the same setpoint must reach it.
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = "f", RawTags = ModbusRawTags.Entries([Hr("A", 10)]), WriteOnChangeOnly = true,
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
var (drv, t) = await StartAsync(opts);
|
||||
await using var _ = drv;
|
||||
int Fc06() => t.Sent.Count(s => s.Pdu[0] == 0x06);
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("A", (short)5)], TestContext.Current.CancellationToken);
|
||||
await drv.WriteAsync([new WriteRequest("A", (short)5)], TestContext.Current.CancellationToken);
|
||||
Fc06().ShouldBe(1, "positive control: an identical re-write is suppressed while nothing failed");
|
||||
|
||||
t.Handler = (_, pdu, _) => pdu[0] == 0x03 ? Task.FromException<byte[]>(new IOException("dropped")) : null;
|
||||
await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
|
||||
t.Handler = null;
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("A", (short)5)], TestContext.Current.CancellationToken);
|
||||
Fc06().ShouldBe(2, "after a failed read the device value is unknown; the write must go to the wire");
|
||||
}
|
||||
|
||||
// ---------------- lifecycle generation ----------------
|
||||
|
||||
[Fact]
|
||||
public async Task A_superseded_initialize_never_publishes_over_the_shutdown_that_overtook_it()
|
||||
{
|
||||
var t = new ScriptedTransport { ConnectGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) };
|
||||
var drv = new ModbusDriver(Opts(Hr("A", 10)), "m1", _ => t) { LifecycleGateTimeout = TimeSpan.FromSeconds(1) };
|
||||
var init = drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
await t.ConnectEntered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||
|
||||
// The init is wedged in its connect; shutdown waits out the gate timeout and tears down anyway.
|
||||
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
|
||||
t.ConnectGate.SetResult(); // the abandoned connect finally completes
|
||||
await Should.ThrowAsync<OperationCanceledException>(() => init);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown, "the superseded init must not publish Healthy");
|
||||
t.DisposeCount.ShouldBe(1, "the superseded init releases the transport it built");
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => drv.ReadAsync(["A"], TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_read_that_straddles_a_reinitialize_does_not_write_health_about_the_new_transport()
|
||||
{
|
||||
var first = new ScriptedTransport();
|
||||
var second = new ScriptedTransport();
|
||||
var release = new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
first.Handler = (_, _, _) => { entered.TrySetResult(); return release.Task; };
|
||||
var calls = 0;
|
||||
var drv = new ModbusDriver(Opts(Hr("A", 10)), "m1", _ => Interlocked.Increment(ref calls) == 1 ? first : second);
|
||||
await using var _ = drv;
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var read = drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
|
||||
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); // re-init onto `second`
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
|
||||
release.SetException(new IOException("old socket died")); // the straddling read now fails
|
||||
(await read)[0].StatusCode.ShouldBe(BadCommunicationError);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a read on the superseded transport must not degrade the new one");
|
||||
|
||||
// Positive control: a failure on the CURRENT transport still degrades.
|
||||
second.Handler = (_, _, _) => Task.FromException<byte[]>(new IOException("new socket died"));
|
||||
await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reinitialize_with_a_new_tag_set_drops_tags_the_new_config_no_longer_has()
|
||||
{
|
||||
var t = new ScriptedTransport();
|
||||
var drv = new ModbusDriver(Opts(Hr("Old", 10)), "m1", _ => t);
|
||||
await using var _ = drv;
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
(await drv.ReadAsync(["Old"], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(Good);
|
||||
|
||||
var config = JsonSerializer.Serialize(new
|
||||
{
|
||||
host = "f",
|
||||
probe = new { enabled = false },
|
||||
rawTags = ModbusRawTags.Entries([Hr("New", 11)]),
|
||||
});
|
||||
await drv.InitializeAsync(config, TestContext.Current.CancellationToken);
|
||||
|
||||
var r = await drv.ReadAsync(["Old", "New"], TestContext.Current.CancellationToken);
|
||||
r[0].StatusCode.ShouldBe(BadNodeIdUnknown, "a re-init publishes a fresh table, it does not merge into the old one");
|
||||
r[1].StatusCode.ShouldBe(Good);
|
||||
}
|
||||
|
||||
// ---------------- complete teardown ----------------
|
||||
|
||||
[Fact]
|
||||
public async Task Shutdown_joins_the_probe_loop_so_no_host_transition_fires_after_it_returns()
|
||||
{
|
||||
var t = new ScriptedTransport();
|
||||
var probeBlocked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var probes = 0;
|
||||
t.Handler = async (_, pdu, ct) =>
|
||||
{
|
||||
if (Interlocked.Increment(ref probes) == 1) return await t.Bank.SendAsync(1, pdu, ct); // first probe: Running
|
||||
probeBlocked.TrySetResult();
|
||||
try { await Task.Delay(Timeout.Infinite, ct); }
|
||||
catch (OperationCanceledException) { }
|
||||
throw new ObjectDisposedException("transport"); // a teardown-time failure, NOT an OCE
|
||||
};
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = "f", RawTags = ModbusRawTags.Entries([Hr("A", 10)]),
|
||||
Probe = new ModbusProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50), Timeout = TimeSpan.FromSeconds(30) },
|
||||
};
|
||||
var drv = new ModbusDriver(opts, "m1", _ => t);
|
||||
var running = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var afterShutdown = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var shutdownReturned = false;
|
||||
drv.OnHostStatusChanged += (_, e) =>
|
||||
{
|
||||
if (e.NewState == HostState.Running) running.TrySetResult();
|
||||
if (Volatile.Read(ref shutdownReturned)) afterShutdown.TrySetResult();
|
||||
};
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
await running.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); // positive control
|
||||
await probeBlocked.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||
|
||||
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
Volatile.Write(ref shutdownReturned, true);
|
||||
|
||||
await Should.ThrowAsync<TimeoutException>(
|
||||
() => afterShutdown.Task.WaitAsync(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
private sealed class ListLogger : ILogger<ModbusDriver>
|
||||
{
|
||||
public System.Collections.Concurrent.ConcurrentQueue<string> Messages { get; } = new();
|
||||
public IEnumerable<string> Warnings => Messages.Where(m => m.StartsWith("Warning:", StringComparison.Ordinal));
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
=> Messages.Enqueue($"{logLevel}: {formatter(state, exception)}");
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ public sealed class ModbusLoggerInjectionTests
|
||||
|
||||
// Scan 1 — coalesced read fails. Expect exactly one warning.
|
||||
await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None);
|
||||
var warnings = logger.Entries.Where(e => e.Level == LogLevel.Warning).ToList();
|
||||
var warnings = logger.Entries.Where(e => e.Level == LogLevel.Warning && e.Message.Contains("auto-prohibited")).ToList();
|
||||
warnings.Count.ShouldBe(1);
|
||||
warnings[0].Message.ShouldContain("drv-logged");
|
||||
warnings[0].Message.ShouldContain("Start=100");
|
||||
@@ -102,7 +102,13 @@ public sealed class ModbusLoggerInjectionTests
|
||||
// the prohibition and skips the merge; even if it didn't, the de-dupe in
|
||||
// RecordAutoProhibition would suppress).
|
||||
await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None);
|
||||
logger.Entries.Count(e => e.Level == LogLevel.Warning).ShouldBe(1, "re-fire of same range stays silent");
|
||||
logger.Entries.Count(e => e.Level == LogLevel.Warning && e.Message.Contains("auto-prohibited"))
|
||||
.ShouldBe(1, "re-fire of same range stays silent");
|
||||
// The protected register's per-tag fallback is a device refusal: one Warning for that tag across
|
||||
// both scans (once-per-tag), and none for its readable neighbours.
|
||||
var refusals = logger.Entries.Where(e => e.Level == LogLevel.Warning && e.Message.Contains("refused")).ToList();
|
||||
refusals.Count.ShouldBe(1);
|
||||
refusals[0].Message.ShouldContain("T102");
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 P6: region/type compatibility is enforced at the tag factory (and so at the deploy gate
|
||||
/// and the AdminUI parse consult). A <c>HoldingRegisters</c> + <c>Bool</c> tag used to throw out of the
|
||||
/// coalescing planner and kill the whole subscription's poll cycle; <c>Coils</c> + <c>Int16</c> silently
|
||||
/// returned a bool into an Int32 node.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusRegionTypeValidationTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":1,"dataType":"Bool"}""")]
|
||||
[InlineData("""{"region":"InputRegisters","address":1,"dataType":"Bool"}""")]
|
||||
public void A_register_region_with_Bool_is_rejected(string json)
|
||||
{
|
||||
// That pair never read (RegisterCount threw), so refusing it breaks nothing that worked.
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", out _).ShouldBeFalse();
|
||||
ModbusTagDefinitionFactory.Inspect(json).ShouldContain(w => w.Contains("register"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix round 1 (review Important #2): the pre-#741 editor defaulted dataType to Int16 and persisted it for
|
||||
/// coil / DI tags, and the old driver ignored it for bit regions — those tags worked and must keep loading.
|
||||
/// A bit region with any type is coerced to Bool and draws no deploy warning (a warning can be promoted to
|
||||
/// a blocking error by TagConfigValidationMode.Error, which would refuse an existing config).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("""{"region":"Coils","address":3,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0}""")]
|
||||
[InlineData("""{"region":"DiscreteInputs","address":1,"dataType":"Float32"}""")]
|
||||
public void A_legacy_bit_region_tag_with_a_register_type_still_loads_as_Bool(string legacyJson)
|
||||
{
|
||||
ModbusTagDefinitionFactory.FromTagConfig(legacyJson, "T", out var def).ShouldBeTrue();
|
||||
def.DataType.ShouldBe(ModbusDataType.Bool);
|
||||
ModbusTagDefinitionFactory.Inspect(legacyJson).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_legacy_Int16_coil_tag_reads_and_writes_the_coil()
|
||||
{
|
||||
var fake = new ModbusDriverTests.FakeTransport();
|
||||
fake.Coils[3] = true;
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = "f", Probe = new ModbusProbeOptions { Enabled = false },
|
||||
RawTags = [new RawTagEntry("Run", """{"region":"Coils","address":3,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0}""", false)],
|
||||
};
|
||||
await using var drv = new ModbusDriver(opts, "m1", _ => fake);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
(await drv.ReadAsync(["Run"], TestContext.Current.CancellationToken))[0].Value.ShouldBe(true);
|
||||
(await drv.WriteAsync([new WriteRequest("Run", false)], TestContext.Current.CancellationToken))[0].StatusCode.ShouldBe(0u);
|
||||
fake.Coils[3].ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"region":"Coils","address":1,"dataType":"Bool"}""", ModbusDataType.Bool)]
|
||||
[InlineData("""{"region":"Coils","address":1}""", ModbusDataType.Bool)]
|
||||
[InlineData("""{"region":"DiscreteInputs","address":1}""", ModbusDataType.Bool)]
|
||||
[InlineData("""{"region":"InputRegisters","address":1,"dataType":"BitInRegister","bitIndex":3}""", ModbusDataType.BitInRegister)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":1}""", ModbusDataType.Int16)]
|
||||
public void Compatible_region_and_type_is_accepted(string json, ModbusDataType expected)
|
||||
{
|
||||
// Positive control for the rejection theory, including the absent-dataType default per region.
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", out var def).ShouldBeTrue();
|
||||
def.DataType.ShouldBe(expected);
|
||||
ModbusTagDefinitionFactory.Inspect(json).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_mis_typed_tag_no_longer_kills_the_coalesced_poll_of_its_neighbours()
|
||||
{
|
||||
var fake = new ModbusDriverTests.FakeTransport();
|
||||
fake.HoldingRegisters[100] = 7;
|
||||
fake.HoldingRegisters[102] = 9;
|
||||
var good1 = new ModbusTagDefinition("G1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
|
||||
var good2 = new ModbusTagDefinition("G2", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = "f",
|
||||
MaxReadGap = 5,
|
||||
RawTags =
|
||||
[
|
||||
ModbusRawTags.Entry(good1),
|
||||
new RawTagEntry("Bad", """{"region":"HoldingRegisters","address":101,"dataType":"Bool"}""", false),
|
||||
ModbusRawTags.Entry(good2),
|
||||
],
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var drv = new ModbusDriver(opts, "m1", _ => fake);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var r = await drv.ReadAsync(["G1", "Bad", "G2"], TestContext.Current.CancellationToken);
|
||||
r[0].Value.ShouldBe((short)7);
|
||||
r[1].StatusCode.ShouldBe(0x80340000u); // BadNodeIdUnknown — skipped at init, reported
|
||||
r[2].Value.ShouldBe((short)9);
|
||||
drv.GetSkippedTags()!.Sample.ShouldContain(s => s.RawPath == "Bad");
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,17 @@ public sealed class ModbusRtuFramingTests
|
||||
pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A }); // fc + byteCount + data, no addr/CRC
|
||||
}
|
||||
|
||||
/// <summary>FC23 (Read/Write Multiple) replies like a read: byte count + data. It used to be sized as a
|
||||
/// fixed 4-byte write echo and desync.</summary>
|
||||
[Fact]
|
||||
public async Task ReadResponse_FC23_returns_bare_pdu()
|
||||
{
|
||||
var body = new byte[] { 0x01, 0x17, 0x04, 0x00, 0x0A, 0x00, 0x0B };
|
||||
await using var s = Canned(ModbusCrc.Append(body));
|
||||
var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
|
||||
pdu.ShouldBe(new byte[] { 0x17, 0x04, 0x00, 0x0A, 0x00, 0x0B });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadResponse_exception_frame_throws_ModbusException()
|
||||
{
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 U3: <c>String[]</c> and <c>BitInRegister[]</c> tags read fine; their writes used to fail
|
||||
/// <c>BadInternalError</c> (no element width for String, <c>Convert.ToBoolean(bool[])</c> for the bit
|
||||
/// array). Both now encode <c>count</c> elements at the tag's element width; a length mismatch is
|
||||
/// <c>BadOutOfRange</c>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusStringAndBitArrayWriteTests
|
||||
{
|
||||
private static async Task<(ModbusDriver, ScriptedTransport)> StartAsync(params ModbusTagDefinition[] tags)
|
||||
{
|
||||
var t = new ScriptedTransport();
|
||||
var d = new ModbusDriver(new ModbusDriverOptions
|
||||
{
|
||||
Host = "f", RawTags = ModbusRawTags.Entries(tags), Probe = new ModbusProbeOptions { Enabled = false },
|
||||
}, "m1", _ => t);
|
||||
await d.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
return (d, t);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task String_array_write_encodes_each_element_at_its_register_width()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("S", ModbusRegion.HoldingRegisters, 10, ModbusDataType.String,
|
||||
StringLength: 3, ArrayCount: 2); // 2 registers per element
|
||||
var (drv, t) = await StartAsync(tag);
|
||||
await using var _ = drv;
|
||||
|
||||
var w = await drv.WriteAsync([new WriteRequest("S", new[] { "AB", "XYZ" })], TestContext.Current.CancellationToken);
|
||||
|
||||
w[0].StatusCode.ShouldBe(0u);
|
||||
t.Bank.HoldingRegisters[10].ShouldBe((ushort)(('A' << 8) | 'B'));
|
||||
t.Bank.HoldingRegisters[11].ShouldBe((ushort)0);
|
||||
t.Bank.HoldingRegisters[12].ShouldBe((ushort)(('X' << 8) | 'Y'));
|
||||
t.Bank.HoldingRegisters[13].ShouldBe((ushort)('Z' << 8));
|
||||
// Round trip through the read path.
|
||||
(await drv.ReadAsync(["S"], TestContext.Current.CancellationToken))[0].Value.ShouldBe(new[] { "AB", "XYZ" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BitInRegister_array_write_sets_only_the_addressed_bit_of_each_register()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("B", ModbusRegion.HoldingRegisters, 30, ModbusDataType.BitInRegister,
|
||||
BitIndex: 4, ArrayCount: 3);
|
||||
var (drv, t) = await StartAsync(tag);
|
||||
await using var _ = drv;
|
||||
t.Bank.HoldingRegisters[30] = 0xFFFF;
|
||||
t.Bank.HoldingRegisters[31] = 0x0001;
|
||||
t.Bank.HoldingRegisters[32] = 0x8000;
|
||||
|
||||
var w = await drv.WriteAsync([new WriteRequest("B", new object[] { false, true, 1 })], TestContext.Current.CancellationToken);
|
||||
|
||||
w[0].StatusCode.ShouldBe(0u);
|
||||
t.Bank.HoldingRegisters[30].ShouldBe((ushort)0xFFEF, "bit 4 cleared, neighbours kept");
|
||||
t.Bank.HoldingRegisters[31].ShouldBe((ushort)0x0011);
|
||||
t.Bank.HoldingRegisters[32].ShouldBe((ushort)0x8010);
|
||||
(await drv.ReadAsync(["B"], TestContext.Current.CancellationToken))[0].Value.ShouldBe(new[] { false, true, true });
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("S")]
|
||||
[InlineData("B")]
|
||||
public async Task A_length_mismatch_is_BadOutOfRange_and_sends_nothing(string tagName)
|
||||
{
|
||||
var (drv, t) = await StartAsync(
|
||||
new ModbusTagDefinition("S", ModbusRegion.HoldingRegisters, 10, ModbusDataType.String, StringLength: 2, ArrayCount: 2),
|
||||
new ModbusTagDefinition("B", ModbusRegion.HoldingRegisters, 30, ModbusDataType.BitInRegister, BitIndex: 1, ArrayCount: 3));
|
||||
await using var _ = drv;
|
||||
object value = tagName == "S" ? new[] { "only one" } : new[] { true };
|
||||
|
||||
var w = await drv.WriteAsync([new WriteRequest(tagName, value)], TestContext.Current.CancellationToken);
|
||||
|
||||
w[0].StatusCode.ShouldBe(0x803C0000u);
|
||||
t.Sent.Where(s => s.Pdu[0] is 0x06 or 0x10).ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// #741 fix round 4 (review r3 N3): <see cref="ModbusTagAddressResolution"/> is the one authority for which part
|
||||
/// of a Modbus TagConfig names the address. The cross-consumer agreement matrix lives in AdminUI.Tests
|
||||
/// (<c>ModbusAddressAuthorityAgreementTests</c>); these pin the resolver's own classification and refusals.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusTagAddressResolutionTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("""{"region":"InputRegisters","address":5}""", ModbusAddressShape.Keys)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":0,"addressString":"D100"}""", ModbusAddressShape.Keys)]
|
||||
[InlineData("""{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100}""", ModbusAddressShape.Keys)]
|
||||
[InlineData("""{"addressString":"40011:F"}""", ModbusAddressShape.StringOnly)]
|
||||
[InlineData("""{"addressString":"40011:F","addressResolved":true}""", ModbusAddressShape.StringOnly)]
|
||||
[InlineData("""{"addressString":"40011:F","region":"InputRegisters"}""", ModbusAddressShape.StringOnly)]
|
||||
[InlineData("""{"addressString":"ZZ9"}""", ModbusAddressShape.StringOnly)]
|
||||
[InlineData("""{"addressString":5}""", ModbusAddressShape.InvalidAddressString)]
|
||||
[InlineData("""{"addressString":" "}""", ModbusAddressShape.NoAddress)]
|
||||
[InlineData("""{"addressString":null}""", ModbusAddressShape.NoAddress)]
|
||||
[InlineData("""{}""", ModbusAddressShape.NoAddress)]
|
||||
[InlineData("""[1]""", ModbusAddressShape.NoAddress)]
|
||||
[InlineData("""not json""", ModbusAddressShape.NoAddress)]
|
||||
public void Classify_decides_shape_by_key_presence_never_by_the_marker(string json, ModbusAddressShape shape)
|
||||
=> ModbusTagAddressResolution.Classify(json).ShouldBe(shape);
|
||||
|
||||
[Fact]
|
||||
public void A_string_only_blob_is_refused_with_a_reason_when_the_family_cannot_parse_it()
|
||||
{
|
||||
var r = ModbusTagAddressResolution.Resolve("""{"addressString":"D100"}""", ModbusFamily.Generic, MelsecFamily.Q_L_iQR);
|
||||
r.IsResolved.ShouldBeFalse();
|
||||
r.Shape.ShouldBe(ModbusAddressShape.StringOnly);
|
||||
r.Refusal.ShouldNotBeNull().ShouldContain("D100");
|
||||
|
||||
var melsec = ModbusTagAddressResolution.Resolve("""{"addressString":"D100"}""", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR);
|
||||
melsec.Address.ShouldNotBeNull().Address.ShouldBe((ushort)100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Keys_are_read_strictly_and_a_bit_region_is_coerced_to_Bool()
|
||||
{
|
||||
ModbusTagAddressResolution.Resolve("""{"region":"Holding","address":1}""", ModbusFamily.Generic, MelsecFamily.Q_L_iQR)
|
||||
.IsResolved.ShouldBeFalse();
|
||||
ModbusTagAddressResolution.Resolve("""{"region":"HoldingRegisters","address":70000}""", ModbusFamily.Generic, MelsecFamily.Q_L_iQR)
|
||||
.IsResolved.ShouldBeFalse();
|
||||
var coil = ModbusTagAddressResolution.Resolve("""{"region":"Coils","address":3,"dataType":"Int16"}""", ModbusFamily.Generic, MelsecFamily.Q_L_iQR);
|
||||
coil.Address.ShouldNotBeNull().DataType.ShouldBe(ModbusDataType.Bool);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddressStringDescribes_matches_only_the_fields_some_family_parses_it_to()
|
||||
{
|
||||
ModbusTagAddressResolution.AddressStringDescribes("D100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16,
|
||||
ModbusByteOrder.BigEndian, 0, 0).ShouldBeTrue();
|
||||
ModbusTagAddressResolution.AddressStringDescribes("D100", ModbusRegion.HoldingRegisters, 101, ModbusDataType.Int16,
|
||||
ModbusByteOrder.BigEndian, 0, 0).ShouldBeFalse();
|
||||
ModbusTagAddressResolution.AddressStringDescribes("ZZ9", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16,
|
||||
ModbusByteOrder.BigEndian, 0, 0).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Transport self-heal (review 07 P3), single in-gate connect attempt + failed-client disposal (L3)
|
||||
/// and the response-PDU length check (H9).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusTransportSelfHealTests
|
||||
{
|
||||
private static readonly byte[] ReadHr0 = [0x03, 0x00, 0x00, 0x00, 0x01];
|
||||
|
||||
/// <summary>
|
||||
/// P3: two lost replies in a row leave the socket torn down. The NEXT call must reconnect on its own
|
||||
/// instead of failing fast with "Transport not connected" until the 90 s comms-loss watchdog.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task After_two_consecutive_timeouts_the_next_call_reconnects()
|
||||
{
|
||||
await using var server = new ScriptedModbusTcpServer
|
||||
{
|
||||
Script = i => i < 2 ? ScriptedModbusTcpServer.Action.Stall : ScriptedModbusTcpServer.Action.Answer,
|
||||
};
|
||||
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromMilliseconds(500));
|
||||
await transport.ConnectAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// First call: stall → teardown → reconnect → resend → stall again → propagates.
|
||||
await Should.ThrowAsync<ModbusTransportDesyncException>(
|
||||
() => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken));
|
||||
|
||||
// Second call: the socket is gone; the transport must dial again and succeed.
|
||||
var resp = await transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken);
|
||||
resp[0].ShouldBe((byte)0x03);
|
||||
server.AcceptedConnections.ShouldBe(3);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// P3 throttle: after a FAILED connect, calls inside the backoff window fail fast with
|
||||
/// <see cref="ModbusTransportUnavailableException"/> and make no connect attempt; once the window has
|
||||
/// passed the next call dials again. Positive control: the device is back before the throttled call,
|
||||
/// so an un-throttled transport would have reconnected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Failed_connect_is_throttled_then_retried_after_the_window()
|
||||
{
|
||||
await using var server = new ScriptedModbusTcpServer();
|
||||
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2));
|
||||
var now = 1_000_000L;
|
||||
transport.Lifecycle.NowMs = () => now;
|
||||
await transport.ConnectAsync(TestContext.Current.CancellationToken);
|
||||
(await transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken))[0].ShouldBe((byte)0x03);
|
||||
|
||||
server.Stop();
|
||||
// Dead socket → EOF → teardown → ONE reconnect attempt → refused.
|
||||
var first = await Should.ThrowAsync<Exception>(
|
||||
() => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken));
|
||||
ModbusSocketLifecycle.IsSocketLevelFailure(first).ShouldBeTrue();
|
||||
|
||||
server.Start();
|
||||
var acceptedBefore = server.AcceptedConnections;
|
||||
await Should.ThrowAsync<ModbusTransportUnavailableException>(
|
||||
() => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken));
|
||||
server.AcceptedConnections.ShouldBe(acceptedBefore, "a throttled call must not dial");
|
||||
|
||||
now += 60_000; // past any window (MaxDelay default 30 s)
|
||||
(await transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken))[0].ShouldBe((byte)0x03);
|
||||
server.AcceptedConnections.ShouldBe(acceptedBefore + 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// L3: the reconnect never sleeps inside the transport gate. With a 30 s InitialDelay the old
|
||||
/// <c>ConnectWithBackoffAsync</c> held the gate for 30 s before even dialling; now one attempt is made
|
||||
/// and the call returns at once.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Reconnect_after_drop_does_not_sleep_inside_the_gate()
|
||||
{
|
||||
await using var server = new ScriptedModbusTcpServer();
|
||||
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2),
|
||||
reconnect: new ModbusReconnectOptions { InitialDelay = TimeSpan.FromSeconds(30) });
|
||||
await transport.ConnectAsync(TestContext.Current.CancellationToken);
|
||||
server.Stop();
|
||||
|
||||
var call = transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken);
|
||||
var ex = await Should.ThrowAsync<Exception>(() => call.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken));
|
||||
ex.ShouldNotBeOfType<TimeoutException>("the send must not wait out the 30 s backoff inside the gate");
|
||||
}
|
||||
|
||||
/// <summary>H9: an MBAP frame carrying no PDU is a truncated-frame desync, not an IndexOutOfRangeException.</summary>
|
||||
[Fact]
|
||||
public async Task Empty_response_pdu_is_a_truncated_frame_desync()
|
||||
{
|
||||
await using var server = new ScriptedModbusTcpServer
|
||||
{
|
||||
RawReply = (_, header, _) => ScriptedModbusTcpServer.Adu(header, []),
|
||||
};
|
||||
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2), autoReconnect: false);
|
||||
await transport.ConnectAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var ex = await Should.ThrowAsync<ModbusTransportDesyncException>(
|
||||
() => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken));
|
||||
ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.TruncatedFrame);
|
||||
}
|
||||
|
||||
/// <summary>H9: an exception frame missing its exception code is a truncated-frame desync.</summary>
|
||||
[Fact]
|
||||
public async Task Exception_frame_without_code_is_a_truncated_frame_desync()
|
||||
{
|
||||
await using var server = new ScriptedModbusTcpServer
|
||||
{
|
||||
RawReply = (_, header, _) => ScriptedModbusTcpServer.Adu(header, [0x83]),
|
||||
};
|
||||
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2), autoReconnect: false);
|
||||
await transport.ConnectAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var ex = await Should.ThrowAsync<ModbusTransportDesyncException>(
|
||||
() => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken));
|
||||
ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.TruncatedFrame);
|
||||
}
|
||||
|
||||
/// <summary>Positive control for the two H9 tests: a well-formed exception frame still surfaces as a
|
||||
/// <see cref="ModbusException"/> carrying its code.</summary>
|
||||
[Fact]
|
||||
public async Task Well_formed_exception_frame_still_surfaces_as_ModbusException()
|
||||
{
|
||||
await using var server = new ScriptedModbusTcpServer
|
||||
{
|
||||
RawReply = (_, header, _) => ScriptedModbusTcpServer.Adu(header, [0x83, 0x02]),
|
||||
};
|
||||
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2), autoReconnect: false);
|
||||
await transport.ConnectAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var ex = await Should.ThrowAsync<ModbusException>(
|
||||
() => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken));
|
||||
ex.ExceptionCode.ShouldBe((byte)0x02);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Loopback Modbus-TCP stub whose reply to each request is scripted by the test, and whose listener can
|
||||
/// be stopped (connection refused) and restarted on the SAME port. Used by the transport self-heal tests
|
||||
/// that need a device which disappears and comes back.
|
||||
/// </summary>
|
||||
internal sealed class ScriptedModbusTcpServer : IAsyncDisposable
|
||||
{
|
||||
/// <summary>What the server does with one request.</summary>
|
||||
public enum Action
|
||||
{
|
||||
/// <summary>Answer with an FC03-shaped response of zeroed registers.</summary>
|
||||
Answer,
|
||||
/// <summary>Read the request and never answer it (the client's per-op timeout fires).</summary>
|
||||
Stall,
|
||||
/// <summary>Close the socket without answering.</summary>
|
||||
Drop,
|
||||
}
|
||||
|
||||
private TcpListener? _listener;
|
||||
private CancellationTokenSource _stop = new();
|
||||
private int _requests;
|
||||
private int _accepted;
|
||||
|
||||
/// <summary>Optional scripted reply. Receives the 0-based request index and the request PDU; returns
|
||||
/// the raw bytes to write AFTER the MBAP TxId (i.e. the full ADU is built by the test), or null to fall
|
||||
/// through to <see cref="Script"/>.</summary>
|
||||
public Func<int, byte[], byte[], byte[]?>? RawReply { get; set; }
|
||||
|
||||
/// <summary>Per-request action; defaults to <see cref="Action.Answer"/>.</summary>
|
||||
public Func<int, Action> Script { get; set; } = _ => Action.Answer;
|
||||
|
||||
/// <summary>Gets the port the server listens on (stable across <see cref="Stop"/>/<see cref="Start"/>).</summary>
|
||||
public int Port { get; }
|
||||
|
||||
/// <summary>Gets the number of accepted TCP connections.</summary>
|
||||
public int AcceptedConnections => Volatile.Read(ref _accepted);
|
||||
|
||||
/// <summary>Gets the number of requests received.</summary>
|
||||
public int Requests => Volatile.Read(ref _requests);
|
||||
|
||||
public ScriptedModbusTcpServer()
|
||||
{
|
||||
var probe = new TcpListener(IPAddress.Loopback, 0);
|
||||
probe.Start();
|
||||
Port = ((IPEndPoint)probe.LocalEndpoint).Port;
|
||||
probe.Stop();
|
||||
Start();
|
||||
}
|
||||
|
||||
/// <summary>(Re)starts listening on <see cref="Port"/>.</summary>
|
||||
public void Start()
|
||||
{
|
||||
_stop = new CancellationTokenSource();
|
||||
_listener = new TcpListener(IPAddress.Loopback, Port);
|
||||
_listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
|
||||
_listener.Start();
|
||||
var listener = _listener;
|
||||
var token = _stop.Token;
|
||||
_ = Task.Run(() => AcceptLoopAsync(listener, token));
|
||||
}
|
||||
|
||||
/// <summary>Stops listening and closes every live connection — new connects are refused.</summary>
|
||||
public void Stop()
|
||||
{
|
||||
try { _stop.Cancel(); } catch { }
|
||||
try { _listener?.Stop(); } catch { }
|
||||
_listener = null;
|
||||
}
|
||||
|
||||
private async Task AcceptLoopAsync(TcpListener listener, CancellationToken token)
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
TcpClient client;
|
||||
try { client = await listener.AcceptTcpClientAsync(token); }
|
||||
catch { return; }
|
||||
Interlocked.Increment(ref _accepted);
|
||||
_ = Task.Run(() => ServeAsync(client, token));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ServeAsync(TcpClient client, CancellationToken token)
|
||||
{
|
||||
using var _ = client;
|
||||
using var reg = token.Register(() => { try { client.Close(); } catch { } });
|
||||
try
|
||||
{
|
||||
var stream = client.GetStream();
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
var header = new byte[7];
|
||||
if (!await ReadExactly(stream, header, token)) return;
|
||||
var len = (ushort)((header[4] << 8) | header[5]);
|
||||
var pdu = new byte[len - 1];
|
||||
if (!await ReadExactly(stream, pdu, token)) return;
|
||||
var idx = Interlocked.Increment(ref _requests) - 1;
|
||||
|
||||
var raw = RawReply?.Invoke(idx, header, pdu);
|
||||
if (raw is not null)
|
||||
{
|
||||
await stream.WriteAsync(raw, token);
|
||||
await stream.FlushAsync(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (Script(idx))
|
||||
{
|
||||
case Action.Stall:
|
||||
continue;
|
||||
case Action.Drop:
|
||||
client.Client.Shutdown(SocketShutdown.Both);
|
||||
return;
|
||||
}
|
||||
|
||||
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
var respPdu = new byte[2 + qty * 2];
|
||||
respPdu[0] = pdu[0];
|
||||
respPdu[1] = (byte)(qty * 2);
|
||||
await stream.WriteAsync(Adu(header, respPdu), token);
|
||||
await stream.FlushAsync(token);
|
||||
}
|
||||
}
|
||||
catch { /* connection torn down */ }
|
||||
}
|
||||
|
||||
/// <summary>Builds a reply ADU echoing the request's TxId/unit around <paramref name="respPdu"/>.</summary>
|
||||
public static byte[] Adu(byte[] requestHeader, byte[] respPdu)
|
||||
{
|
||||
var respLen = (ushort)(1 + respPdu.Length);
|
||||
var adu = new byte[7 + respPdu.Length];
|
||||
adu[0] = requestHeader[0]; adu[1] = requestHeader[1];
|
||||
adu[4] = (byte)(respLen >> 8); adu[5] = (byte)(respLen & 0xFF);
|
||||
adu[6] = requestHeader[6];
|
||||
Buffer.BlockCopy(respPdu, 0, adu, 7, respPdu.Length);
|
||||
return adu;
|
||||
}
|
||||
|
||||
private static async Task<bool> ReadExactly(Stream s, byte[] buf, CancellationToken token)
|
||||
{
|
||||
var read = 0;
|
||||
while (read < buf.Length)
|
||||
{
|
||||
var n = await s.ReadAsync(buf.AsMemory(read), token);
|
||||
if (n == 0) return false;
|
||||
read += n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
Stop();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory transport whose every send is answered by a test-supplied handler; falls back to the
|
||||
/// register/coil bank fake (<see cref="ModbusDriverTests.FakeTransport"/>) when the handler returns null.
|
||||
/// Counts sends per unit and dispose calls; the connect can be blocked on a gate.
|
||||
/// </summary>
|
||||
internal sealed class ScriptedTransport : IModbusTransport
|
||||
{
|
||||
private readonly ConcurrentDictionary<byte, int> _sendsByUnit = new();
|
||||
private int _disposeCount;
|
||||
|
||||
/// <summary>The bank answering un-scripted sends.</summary>
|
||||
public ModbusDriverTests.FakeTransport Bank { get; } = new();
|
||||
|
||||
/// <summary>Per-send handler; return null to fall through to <see cref="Bank"/>.</summary>
|
||||
public Func<byte, byte[], CancellationToken, Task<byte[]>?>? Handler { get; set; }
|
||||
|
||||
/// <summary>When set, <see cref="ConnectAsync"/> waits for it before completing.</summary>
|
||||
public TaskCompletionSource? ConnectGate { get; set; }
|
||||
|
||||
/// <summary>Signalled when <see cref="ConnectAsync"/> is entered.</summary>
|
||||
public TaskCompletionSource ConnectEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public int DisposeCount => Volatile.Read(ref _disposeCount);
|
||||
|
||||
public int SendsTo(byte unit) => _sendsByUnit.TryGetValue(unit, out var n) ? n : 0;
|
||||
|
||||
public int TotalSends => _sendsByUnit.Values.Sum();
|
||||
|
||||
/// <summary>Every PDU sent, in order.</summary>
|
||||
public ConcurrentQueue<(byte Unit, byte[] Pdu)> Sent { get; } = new();
|
||||
|
||||
/// <summary>The (function code, start, quantity) of every sent request with that function code.</summary>
|
||||
public List<(byte Fc, ushort Start, ushort Qty)> Requests(byte fc) =>
|
||||
[.. Sent.Where(s => s.Pdu[0] == fc).Select(s => (s.Pdu[0], (ushort)((s.Pdu[1] << 8) | s.Pdu[2]), (ushort)((s.Pdu[3] << 8) | s.Pdu[4])))];
|
||||
|
||||
public async Task ConnectAsync(CancellationToken ct)
|
||||
{
|
||||
ConnectEntered.TrySetResult();
|
||||
if (ConnectGate is { } gate) await gate.Task.WaitAsync(ct);
|
||||
}
|
||||
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
_sendsByUnit.AddOrUpdate(unitId, 1, (_, n) => n + 1);
|
||||
Sent.Enqueue((unitId, pdu));
|
||||
var scripted = Handler?.Invoke(unitId, pdu, ct);
|
||||
return scripted ?? Bank.SendAsync(unitId, pdu, ct);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
Interlocked.Increment(ref _disposeCount);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ public sealed class ModbusDriverPageFormSerializationTests
|
||||
UseFC15ForSingleCoilWrites = true,
|
||||
UseFC16ForSingleRegisterWrites = true,
|
||||
DisableFC23 = true,
|
||||
UseFC23ForMultiRegisterWrites = true,
|
||||
DisableReadCoalescing = true,
|
||||
WriteOnChangeOnly = true,
|
||||
AutoReconnect = false,
|
||||
Family = ModbusFamily.DL205,
|
||||
@@ -72,6 +74,8 @@ public sealed class ModbusDriverPageFormSerializationTests
|
||||
back.UseFC15ForSingleCoilWrites.ShouldBeTrue();
|
||||
back.UseFC16ForSingleRegisterWrites.ShouldBeTrue();
|
||||
back.DisableFC23.ShouldBeTrue();
|
||||
back.UseFC23ForMultiRegisterWrites.ShouldBeTrue();
|
||||
back.DisableReadCoalescing.ShouldBeTrue();
|
||||
back.WriteOnChangeOnly.ShouldBeTrue();
|
||||
back.AutoReconnect.ShouldBeFalse();
|
||||
back.Family.ShouldBe(ModbusFamily.DL205);
|
||||
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Review r3 N3 (#741, fix round 4): <b>one authority for a Modbus tag's address</b>. Every persisted
|
||||
/// <c>TagConfig</c> shape goes through EVERY consumer that decides, displays or rewrites the address. They
|
||||
/// must all agree with the driver, under every family:
|
||||
/// <list type="bullet">
|
||||
/// <item>the driver's tag reader (<c>ModbusTagDefinitionFactory.FromTagConfig</c> with the driver's
|
||||
/// family, the exact call <c>ModbusDriver</c> makes at initialize);</item>
|
||||
/// <item>the deploy gate (<c>EquipmentTagConfigInspector.Inspect</c>);</item>
|
||||
/// <item>the editor model's load (what its fields claim), save (incl. a Writable toggle) and
|
||||
/// <c>Validate</c>;</item>
|
||||
/// <item>the CSV export → import round trip through the real <c>RawTagCsvMapper</c> (the export residual,
|
||||
/// not the stored blob, is the import base).</item>
|
||||
/// </list>
|
||||
/// The expected verdict per family is pinned in the table, so the authority itself cannot drift either.
|
||||
/// Three rounds converged on the rule one consumer at a time; this matrix is what makes a new divergence
|
||||
/// fail in one place.
|
||||
/// </summary>
|
||||
public sealed class ModbusAddressAuthorityAgreementTests
|
||||
{
|
||||
private static readonly (ModbusFamily Family, MelsecFamily Sub, string Label)[] Families =
|
||||
[
|
||||
(ModbusFamily.Generic, MelsecFamily.Q_L_iQR, "Generic"),
|
||||
(ModbusFamily.DL205, MelsecFamily.Q_L_iQR, "DL205"),
|
||||
(ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, "MELSEC-Q"),
|
||||
(ModbusFamily.MELSEC, MelsecFamily.F_iQF, "MELSEC-F"),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Shape name, persisted blob, and the expected driver verdict per family in the order of
|
||||
/// <see cref="Families"/> (<c>IR5</c> = InputRegisters[5], <c>-</c> = refused).
|
||||
/// </summary>
|
||||
public static TheoryData<string, string, string> Shapes => new()
|
||||
{
|
||||
{ "keys only", """{"region":"InputRegisters","address":5}""", "IR5|IR5|IR5|IR5" },
|
||||
{ "pre-lane keys + string", """{"region":"InputRegisters","address":5,"dataType":"Int16","addressString":"40011:F"}""", "IR5|IR5|IR5|IR5" },
|
||||
{ "pre-lane stamped HR/0 + string",
|
||||
"""{"region":"HoldingRegisters","address":0,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0,"addressString":"D100"}""",
|
||||
"HR0|HR0|HR0|HR0" },
|
||||
{ "string only", """{"addressString":"40011:F"}""", "HR10|HR10|HR10|HR10" },
|
||||
{ "string only, family-native", """{"addressString":"D100"}""", "-|-|HR100|HR100" },
|
||||
{ "string + marker + keys", """{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100,"dataType":"Int16"}""", "HR100|HR100|HR100|HR100" },
|
||||
{ "string + marker, no keys", """{"addressString":"40011:F","addressResolved":true}""", "HR10|HR10|HR10|HR10" },
|
||||
{ "string + marker, no keys, family-native", """{"addressString":"D100","addressResolved":true}""", "-|-|HR100|HR100" },
|
||||
{ "malformed string", """{"addressString":"ZZ9"}""", "-|-|-|-" },
|
||||
{ "malformed string + marker", """{"addressString":"ZZ9","addressResolved":true}""", "-|-|-|-" },
|
||||
{ "malformed string + keys", """{"region":"InputRegisters","address":5,"addressString":"ZZ9"}""", "IR5|IR5|IR5|IR5" },
|
||||
{ "non-string addressString", """{"addressString":5}""", "-|-|-|-" },
|
||||
{ "string + one key", """{"addressString":"40011:F","region":"InputRegisters"}""", "HR10|HR10|HR10|HR10" },
|
||||
{ "no address", """{"writable":true}""", "-|-|-|-" },
|
||||
{ "bit region, legacy Int16", """{"region":"Coils","address":3,"dataType":"Int16"}""", "CO3|CO3|CO3|CO3" },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Shapes))]
|
||||
public void Every_consumer_agrees_with_the_driver_on_every_persisted_shape(string shape, string blob, string expected)
|
||||
{
|
||||
var want = expected.Split('|');
|
||||
var anyServed = want.Any(v => v != "-");
|
||||
|
||||
// 1. The driver (the authority) — pinned, so the authority cannot drift unnoticed either.
|
||||
for (var i = 0; i < Families.Length; i++)
|
||||
{
|
||||
Verdict(blob, i).ShouldBe(want[i], $"{shape}: driver / {Families[i].Label}");
|
||||
}
|
||||
|
||||
// 2. The deploy gate: clean exactly when some family's driver serves the tag.
|
||||
(EquipmentTagConfigInspector.Inspect(DriverTypeNames.Modbus, blob).Count == 0)
|
||||
.ShouldBe(anyServed, $"{shape}: deploy gate accepts ⇔ some driver serves it");
|
||||
|
||||
// 3. Editor load: when the model presents its fields as the address, they ARE the driver's address.
|
||||
var loaded = ModbusTagConfigModel.FromJson(blob);
|
||||
if (!loaded.IsStringOnly)
|
||||
{
|
||||
for (var i = 0; i < Families.Length; i++)
|
||||
{
|
||||
if (want[i] == "-") continue;
|
||||
FieldsVerdict(loaded).ShouldBe(want[i], $"{shape}: editor fields / {Families[i].Label}");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Editor save, untouched and after a Writable toggle (the round trip that remapped N1 / N3).
|
||||
var untouched = ModbusTagConfigModel.FromJson(blob).ToJson();
|
||||
var toggled = ModbusTagConfigModel.FromJson(blob);
|
||||
toggled.Writable = false;
|
||||
var toggledJson = toggled.ToJson();
|
||||
for (var i = 0; i < Families.Length; i++)
|
||||
{
|
||||
Verdict(untouched, i).ShouldBe(want[i], $"{shape}: editor save / {Families[i].Label}");
|
||||
Verdict(toggledJson, i).ShouldBe(want[i], $"{shape}: editor save after Writable toggle / {Families[i].Label}");
|
||||
}
|
||||
|
||||
// 5. Validate: accepts exactly what some family's driver serves.
|
||||
(ModbusTagConfigModel.FromJson(blob).Validate() is null).ShouldBe(anyServed, $"{shape}: Validate");
|
||||
|
||||
// 6. CSV export → import through the production mapper (the residual is the import base).
|
||||
var csv = RawTagCsvMapper.Export(DriverTypeNames.Modbus,
|
||||
[new RawCsvExportTag(null, "t", "Int16", TagAccessLevel.ReadWrite, false, null, blob)]);
|
||||
var parsed = RawTagCsvMapper.Parse(DriverTypeNames.Modbus, csv);
|
||||
parsed.FatalError.ShouldBeNull();
|
||||
var row = parsed.Rows.ShouldHaveSingleItem();
|
||||
if (!row.Ok)
|
||||
{
|
||||
anyServed.ShouldBeFalse($"{shape}: CSV import refused a tag the driver serves ({row.Error})");
|
||||
}
|
||||
else
|
||||
{
|
||||
for (var i = 0; i < Families.Length; i++)
|
||||
{
|
||||
Verdict(row.ImportRow!.Tag.TagConfig, i).ShouldBe(want[i], $"{shape}: CSV export → import / {Families[i].Label}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string Verdict(string json, int familyIndex)
|
||||
{
|
||||
var (family, sub, _) = Families[familyIndex];
|
||||
return ModbusTagDefinitionFactory.FromTagConfig(json, "T", family, sub, out var def)
|
||||
? Short(def.Region) + def.Address
|
||||
: "-";
|
||||
}
|
||||
|
||||
private static string FieldsVerdict(ModbusTagConfigModel m) => Short(m.Region) + m.Address;
|
||||
|
||||
private static string Short(ModbusRegion r) => r switch
|
||||
{
|
||||
ModbusRegion.HoldingRegisters => "HR",
|
||||
ModbusRegion.InputRegisters => "IR",
|
||||
ModbusRegion.Coils => "CO",
|
||||
ModbusRegion.DiscreteInputs => "DI",
|
||||
_ => r.ToString(),
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ CSV metadata (review r3 m1 / m3)
|
||||
|
||||
private static RawCsvReviewRow RoundTrip(string blob, Func<string, string>? editCsv = null)
|
||||
{
|
||||
var csv = RawTagCsvMapper.Export(DriverTypeNames.Modbus,
|
||||
[new RawCsvExportTag(null, "t", "Int16", TagAccessLevel.ReadWrite, false, null, blob)]);
|
||||
if (editCsv is not null) csv = editCsv(csv);
|
||||
return RawTagCsvMapper.Parse(DriverTypeNames.Modbus, csv).Rows.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
/// <summary>m1: an UNCHANGED export → import of a pinned row keeps its address string and marker.</summary>
|
||||
[Fact]
|
||||
public void An_unchanged_csv_round_trip_of_a_pinned_row_keeps_its_address_string_and_marker()
|
||||
{
|
||||
const string pinned = """{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0}""";
|
||||
var row = RoundTrip(pinned);
|
||||
row.Ok.ShouldBeTrue(row.Error);
|
||||
var json = row.ImportRow!.Tag.TagConfig;
|
||||
json.ShouldContain("\"addressString\":\"D100\"");
|
||||
json.ShouldContain("\"addressResolved\":true");
|
||||
row.TypedOverrodeFallback.ShouldBeEmpty("nothing was overridden");
|
||||
ModbusTagConfigModel.FromJson(json).IsPinned.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>m1 positive control: an address cell that CONTRADICTS the string drops it, and the grid says so.</summary>
|
||||
[Fact]
|
||||
public void A_csv_address_cell_that_contradicts_the_string_drops_it_and_the_review_grid_reports_it()
|
||||
{
|
||||
const string pinned = """{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0}""";
|
||||
// Edit the exported Address cell 100 → 101.
|
||||
var row = RoundTrip(pinned, csv => csv.Replace(",HoldingRegisters,100,", ",HoldingRegisters,101,", StringComparison.Ordinal));
|
||||
row.Ok.ShouldBeTrue(row.Error);
|
||||
var json = row.ImportRow!.Tag.TagConfig;
|
||||
json.ShouldNotContain("addressString");
|
||||
json.ShouldNotContain("addressResolved");
|
||||
row.TypedOverrodeFallback.ShouldNotBeEmpty("the dropped string is reported, not silent");
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
|
||||
def.Address.ShouldBe((ushort)101);
|
||||
}
|
||||
|
||||
/// <summary>m3: the n5 case on an UNMARKED string-only row — typed address cells are applied and drop the string.</summary>
|
||||
[Fact]
|
||||
public void A_typed_csv_address_cell_on_an_unmarked_string_only_row_is_applied_and_drops_the_string()
|
||||
{
|
||||
var map = CsvColumnMap.Resolve(DriverTypeNames.Modbus).ShouldNotBeNull();
|
||||
var merged = map.ApplyTypedColumns("""{"addressString":"40011:F"}""",
|
||||
new Dictionary<string, string>(StringComparer.Ordinal) { ["Region"] = "InputRegisters", ["Address"] = "7" });
|
||||
|
||||
merged.ShouldNotContain("addressString");
|
||||
foreach (var (family, sub, label) in Families)
|
||||
{
|
||||
ModbusTagDefinitionFactory.FromTagConfig(merged, "T", family, sub, out var def).ShouldBeTrue(label);
|
||||
def.Region.ShouldBe(ModbusRegion.InputRegisters, label);
|
||||
def.Address.ShouldBe((ushort)7, label);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>N3, by name: the marker without keys is string-only for the editor, its save and its CSV export.</summary>
|
||||
[Fact]
|
||||
public void A_marker_without_keys_is_string_only_and_never_saved_as_HR0()
|
||||
{
|
||||
var m = ModbusTagConfigModel.FromJson("""{"addressString":"40011:F","addressResolved":true}""");
|
||||
m.IsStringOnly.ShouldBeTrue();
|
||||
m.IsPinned.ShouldBeFalse("a marker without keys pins nothing");
|
||||
m.Writable = false;
|
||||
var saved = m.ToJson();
|
||||
saved.ShouldNotContain("\"address\":");
|
||||
saved.ShouldNotContain("\"region\":");
|
||||
CsvColumnMap.Resolve(DriverTypeNames.Modbus)!.ReadTypedColumns(saved).ShouldNotContainKey("Address");
|
||||
}
|
||||
|
||||
/// <summary>The editor never writes default keys it did not resolve: a Writable toggle on an address-less blob
|
||||
/// does not author HoldingRegisters[0]; setting a field does.</summary>
|
||||
[Fact]
|
||||
public void An_address_less_blob_gains_keys_only_when_a_field_is_authored()
|
||||
{
|
||||
var m = ModbusTagConfigModel.FromJson("{}");
|
||||
m.Writable = false;
|
||||
m.ToJson().ShouldNotContain("\"region\":");
|
||||
m.HasKeyAddress.ShouldBeFalse();
|
||||
|
||||
m.Address = 0; // an explicit authoring of HR[0] is honoured
|
||||
m.HasKeyAddress.ShouldBeTrue();
|
||||
ModbusTagDefinitionFactory.FromTagConfig(m.ToJson(), "T", out var def).ShouldBeTrue();
|
||||
def.Address.ShouldBe((ushort)0);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,23 @@ public sealed class ModbusDriverFormModelTests
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
/// <summary>Review r1 n2: the lane's new form keys survive FromOptions → ToOptions, and their defaults keep
|
||||
/// the historical wire (FC16, coalescing on at gap 8 only when the key is absent).</summary>
|
||||
[Fact]
|
||||
public void Form_round_trips_the_FC23_opt_in_and_coalescing_keys()
|
||||
{
|
||||
var form = new ModbusDriverForm.FormModel { UseFC23ForMultiRegisterWrites = true, DisableFC23 = true, DisableReadCoalescing = true, MaxReadGap = 0 };
|
||||
var back = ModbusDriverForm.FormModel.FromOptions(form.ToOptions());
|
||||
back.UseFC23ForMultiRegisterWrites.ShouldBeTrue();
|
||||
back.DisableFC23.ShouldBeTrue();
|
||||
back.DisableReadCoalescing.ShouldBeTrue();
|
||||
back.MaxReadGap.ShouldBe(0);
|
||||
|
||||
var defaults = new ModbusDriverForm.FormModel().ToOptions();
|
||||
defaults.UseFC23ForMultiRegisterWrites.ShouldBeFalse();
|
||||
defaults.MaxReadGap.ShouldBe((ushort)8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_trip_preserves_channel_fields_and_enum()
|
||||
{
|
||||
|
||||
@@ -121,4 +121,190 @@ public sealed class ModbusTagConfigModelTests
|
||||
[InlineData("""{"region":3,"address":1,"dataType":true}""")]
|
||||
public void Validate_accepts_every_enum_value_the_driver_accepts(string json)
|
||||
=> ModbusTagConfigModel.FromJson(json).Validate().ShouldBeNull();
|
||||
|
||||
/// <summary>Review 07 P6: switching region keeps the data type one the driver accepts for it.</summary>
|
||||
[Theory]
|
||||
[InlineData(ModbusDataType.Int16, ModbusRegion.Coils, ModbusDataType.Bool)]
|
||||
[InlineData(ModbusDataType.Float32, ModbusRegion.DiscreteInputs, ModbusDataType.Bool)]
|
||||
[InlineData(ModbusDataType.Bool, ModbusRegion.HoldingRegisters, ModbusDataType.Int16)]
|
||||
[InlineData(ModbusDataType.Float32, ModbusRegion.InputRegisters, ModbusDataType.Float32)]
|
||||
public void SetRegion_keeps_the_type_compatible(ModbusDataType from, ModbusRegion region, ModbusDataType expected)
|
||||
{
|
||||
var m = new ModbusTagConfigModel { DataType = from };
|
||||
m.SetRegion(region);
|
||||
m.Region.ShouldBe(region);
|
||||
m.DataType.ShouldBe(expected);
|
||||
ModbusTagConfigModel.FromJson(m.ToJson()).Validate().ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>A register region with Bool (CSV / API) is refused at authoring, matching the driver.</summary>
|
||||
[Fact]
|
||||
public void Validate_refuses_a_register_region_with_Bool()
|
||||
=> ModbusTagConfigModel.FromJson("""{"region":"HoldingRegisters","address":1,"dataType":"Bool"}""").Validate().ShouldNotBeNull();
|
||||
|
||||
/// <summary>
|
||||
/// Fix round 1: the exact blob the pre-#741 editor persisted for a coil (dataType defaulted to Int16)
|
||||
/// still validates, loads as Bool (what the driver polls), and re-saves as Bool.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_legacy_Int16_coil_blob_validates_and_loads_as_Bool()
|
||||
{
|
||||
const string legacy = """{"region":"Coils","address":3,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0}""";
|
||||
var m = ModbusTagConfigModel.FromJson(legacy);
|
||||
m.Validate().ShouldBeNull();
|
||||
m.DataType.ShouldBe(ModbusDataType.Bool);
|
||||
m.ToJson().ShouldContain("\"dataType\":\"Bool\"");
|
||||
}
|
||||
|
||||
/// <summary>Review 07 U1: the editor resolves an address string with the DRIVER's family and persists
|
||||
/// both the string and the resolved fields; the driver re-resolves the string with its family.</summary>
|
||||
[Fact]
|
||||
public void ResolveAddress_uses_the_driver_family_and_persists_string_and_resolved_fields()
|
||||
{
|
||||
var (family, sub) = ModbusTagConfigModel.ReadFamily("""{"Family":"MELSEC","melsecSubFamily":"Q_L_iQR"}""");
|
||||
family.ShouldBe(ModbusFamily.MELSEC);
|
||||
|
||||
var m = ModbusTagConfigModel.FromJson("{}");
|
||||
m.ResolveAddress("D100", family, sub).ShouldBeNull();
|
||||
m.Region.ShouldBe(ModbusRegion.HoldingRegisters);
|
||||
m.Address.ShouldBe(100);
|
||||
|
||||
var json = m.ToJson();
|
||||
json.ShouldContain("\"addressString\":\"D100\"");
|
||||
json.ShouldContain("\"address\":100");
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
|
||||
def.Address.ShouldBe((ushort)100);
|
||||
ModbusTagConfigModel.FromJson(json).Validate().ShouldBeNull("the family-unaware authoring gate accepts what the MELSEC driver serves");
|
||||
|
||||
// Positive control: under Generic the same text is an error, and nothing is persisted.
|
||||
var g = ModbusTagConfigModel.FromJson("{}");
|
||||
g.ResolveAddress("D100", ModbusFamily.Generic, MelsecFamily.Q_L_iQR).ShouldNotBeNull();
|
||||
g.AddressString.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Fix round 2 (review r1 N1): a string-only tag through CSV import must still resolve its string
|
||||
/// with the driver's family — never the HoldingRegisters/0 defaults the model loads it with.</summary>
|
||||
[Fact]
|
||||
public void Csv_import_of_a_string_only_tag_keeps_resolving_the_string()
|
||||
{
|
||||
var map = CsvColumnMap.Resolve(ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTypeNames.Modbus).ShouldNotBeNull();
|
||||
var merged = map.ApplyTypedColumns("""{"addressString":"D100"}""",
|
||||
new Dictionary<string, string>(StringComparer.Ordinal) { ["Writable"] = "false" });
|
||||
|
||||
ModbusTagDefinitionFactory.FromTagConfig(merged, "T", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, out var melsec).ShouldBeTrue();
|
||||
melsec.Address.ShouldBe((ushort)100, "not HR[0]");
|
||||
melsec.Writable.ShouldBeFalse("the typed column still applied");
|
||||
// Under another family the string is resolved by THAT family (here it does not parse), never HR[0].
|
||||
ModbusTagDefinitionFactory.FromTagConfig(merged, "T", ModbusFamily.Generic, MelsecFamily.Q_L_iQR, out _).ShouldBeFalse();
|
||||
// Export shows blank address cells for it, so an export → re-import cannot author an HR[0] address.
|
||||
map.ReadTypedColumns(merged).ShouldNotContainKey("Region");
|
||||
map.ReadTypedColumns(merged).ShouldNotContainKey("Address");
|
||||
}
|
||||
|
||||
/// <summary>Fix round 2 (review r1 N1): an editor save (e.g. the Writable toggle) of a string-only tag.</summary>
|
||||
[Fact]
|
||||
public void Editor_save_of_a_string_only_tag_keeps_resolving_the_string()
|
||||
{
|
||||
var m = ModbusTagConfigModel.FromJson("""{"addressString":"40011:F"}""");
|
||||
m.IsStringOnly.ShouldBeTrue();
|
||||
m.Writable = false;
|
||||
var json = m.ToJson();
|
||||
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", out var def).ShouldBeTrue();
|
||||
def.Region.ShouldBe(ModbusRegion.HoldingRegisters);
|
||||
def.Address.ShouldBe((ushort)10);
|
||||
def.DataType.ShouldBe(ModbusDataType.Float32);
|
||||
def.Writable.ShouldBeFalse();
|
||||
json.ShouldNotContain("\"address\":");
|
||||
}
|
||||
|
||||
/// <summary>Positive control for the two above: Resolve pins the fields (marker), and a family change then
|
||||
/// cannot remap the tag.</summary>
|
||||
[Fact]
|
||||
public void A_resolved_tag_is_pinned_against_a_later_family_change()
|
||||
{
|
||||
var m = ModbusTagConfigModel.FromJson("{}");
|
||||
m.ResolveAddress("D100", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR).ShouldBeNull();
|
||||
var json = m.ToJson();
|
||||
json.ShouldContain("\"addressResolved\":true");
|
||||
foreach (var family in new[] { ModbusFamily.MELSEC, ModbusFamily.DL205, ModbusFamily.Generic })
|
||||
{
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", family, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
|
||||
def.Address.ShouldBe((ushort)100, $"family {family}");
|
||||
}
|
||||
}
|
||||
|
||||
private const string PreLaneRow = """{"region":"InputRegisters","address":5,"dataType":"Int16","addressString":"40011:F"}""";
|
||||
|
||||
/// <summary>Fix round 3 (review r2 N2): a pre-#741 row (keys + string, no marker) keeps resolving from its
|
||||
/// keys through a model save and a CSV export → import, under every family.</summary>
|
||||
[Fact]
|
||||
public void A_pre_lane_keys_plus_string_row_keeps_its_keys_through_save_and_csv_round_trip()
|
||||
{
|
||||
var m = ModbusTagConfigModel.FromJson(PreLaneRow);
|
||||
m.IsStringOnly.ShouldBeFalse();
|
||||
m.HasLegacyAddressString.ShouldBeTrue();
|
||||
m.Writable = false;
|
||||
var saved = m.ToJson();
|
||||
|
||||
// The production export → import path (review r3 m2): the import base is the export RESIDUAL, not the row.
|
||||
var csv = ZB.MOM.WW.OtOpcUa.AdminUI.Uns.RawTagCsvMapper.Export(ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTypeNames.Modbus,
|
||||
[new ZB.MOM.WW.OtOpcUa.AdminUI.Uns.RawCsvExportTag(null, "t", "Int16",
|
||||
ZB.MOM.WW.OtOpcUa.Configuration.Enums.TagAccessLevel.ReadWrite, false, null, PreLaneRow)]);
|
||||
csv.ShouldContain(",InputRegisters,5,");
|
||||
var row = ZB.MOM.WW.OtOpcUa.AdminUI.Uns.RawTagCsvMapper.Parse(ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTypeNames.Modbus, csv)
|
||||
.Rows.ShouldHaveSingleItem();
|
||||
row.Ok.ShouldBeTrue(row.Error);
|
||||
var reimported = row.ImportRow!.Tag.TagConfig;
|
||||
// The legacy string contradicts the keys under every family, so the import drops it — reported, not silent.
|
||||
reimported.ShouldNotContain("addressString");
|
||||
row.TypedOverrodeFallback.ShouldNotBeEmpty();
|
||||
|
||||
foreach (var json in new[] { saved, reimported })
|
||||
foreach (var family in new[] { ModbusFamily.Generic, ModbusFamily.DL205, ModbusFamily.MELSEC })
|
||||
{
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", family, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
|
||||
def.Region.ShouldBe(ModbusRegion.InputRegisters, family.ToString());
|
||||
def.Address.ShouldBe((ushort)5, family.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Review r2 n5: a typed CSV address cell on a keyless row carrying a (stale) marker is an authored
|
||||
/// address — it is applied and drops the string it contradicts. The unmarked string-only case is
|
||||
/// <c>ModbusAddressAuthorityAgreementTests.A_typed_csv_address_cell_on_an_unmarked_string_only_row_…</c> (r3 m3).</summary>
|
||||
[Fact]
|
||||
public void A_typed_csv_address_cell_on_a_marker_bearing_keyless_row_is_applied_and_drops_the_string()
|
||||
{
|
||||
var map = CsvColumnMap.Resolve(ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTypeNames.Modbus).ShouldNotBeNull();
|
||||
var merged = map.ApplyTypedColumns("""{"addressString":"40011:F","addressResolved":true}""",
|
||||
new Dictionary<string, string>(StringComparer.Ordinal) { ["Region"] = "InputRegisters", ["Address"] = "7" });
|
||||
|
||||
merged.ShouldNotContain("addressString");
|
||||
merged.ShouldNotContain("addressResolved");
|
||||
ModbusTagDefinitionFactory.FromTagConfig(merged, "T", out var def).ShouldBeTrue();
|
||||
def.Region.ShouldBe(ModbusRegion.InputRegisters);
|
||||
def.Address.ShouldBe((ushort)7);
|
||||
}
|
||||
|
||||
/// <summary>Review r2 n6: authoring is never stricter than the driver — a string-only tag's ignored dataType /
|
||||
/// byteOrder keys are not judged (positive control: the same junk on a classic tag is refused).</summary>
|
||||
[Fact]
|
||||
public void Validate_does_not_judge_keys_the_driver_ignores_on_a_string_only_tag()
|
||||
{
|
||||
ModbusTagConfigModel.FromJson("""{"addressString":"40011:F","dataType":"Intt16","byteOrder":"Nope"}""").Validate().ShouldBeNull();
|
||||
ModbusTagConfigModel.FromJson("""{"region":"HoldingRegisters","address":1,"dataType":"Intt16"}""").Validate().ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_hand_edit_after_resolving_drops_the_address_string()
|
||||
{
|
||||
var m = ModbusTagConfigModel.FromJson("{}");
|
||||
m.ResolveAddress("40011:F", ModbusFamily.Generic, MelsecFamily.Q_L_iQR).ShouldBeNull();
|
||||
m.ClearAddressString();
|
||||
m.Address = 50;
|
||||
var json = m.ToJson();
|
||||
json.ShouldNotContain("addressString");
|
||||
ModbusTagDefinitionFactory.FromTagConfig(json, "T", out var def).ShouldBeTrue();
|
||||
def.Address.ShouldBe((ushort)50);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user