Merge Wave-2 integration: Modbus array-write rows + FC15/FC16/FC23 PDU length fix (#741)
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -20,11 +20,24 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// until this table follows.
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Modbus</b> — <c>region: Coils</c> (FC15, any element, at most 1968 coils) and
|
||||
/// <c>region: HoldingRegisters</c> with a fixed-width numeric <c>dataType</c> (FC16) whose
|
||||
/// <c>arrayLength × registers-per-element</c> fits the driver's <c>maxRegistersPerWrite</c> (default
|
||||
/// 123 — the driver refuses rather than chunks a longer FC16). Bit-in-register, String and
|
||||
/// Bool-in-register arrays have no array encoder; input regions are read-only.</item>
|
||||
/// <item><b>Modbus</b> — tags addressed by the <c>region</c> + <c>address</c> KEYS (the
|
||||
/// <c>ModbusTagAddressResolution</c> rule: keys win whenever both are present), read as strictly as the
|
||||
/// driver reads them (<c>address</c> an integer 0–65535; a present <c>region</c> / <c>dataType</c> /
|
||||
/// <c>byteOrder</c> / <c>stringByteOrder</c> string must name a member). <c>region: Coils</c>: FC15, at most
|
||||
/// 1968 coils, with ANY <c>dataType</c> — the resolver coerces a bit region to Bool and never refuses it.
|
||||
/// <c>region: HoldingRegisters</c> (FC16), never Bool: a fixed-width numeric <c>dataType</c>,
|
||||
/// <c>String</c> (<c>(stringLength+1)/2</c> registers per element, <c>stringLength >= 1</c>) or
|
||||
/// <c>BitInRegister</c> (one register per element; <c>bitIndex</c> 0–15), whose
|
||||
/// <c>arrayLength × registers-per-element</c> fits the driver's <c>maxRegistersPerWrite</c> (default 123 —
|
||||
/// the driver refuses rather than chunks a longer FC16) and never more than FC16's protocol ceiling of 123,
|
||||
/// however high the cap is configured (a conformant device refuses a longer FC16, so that write always
|
||||
/// fails). A BitInRegister array is read-modify-written — one unchunked FC03 of the span, then one FC16 —
|
||||
/// so it is also bounded by <c>maxRegistersPerRead</c> (default and protocol ceiling 125). Every span
|
||||
/// (registers or coils) ends at or before address 65535. FC23 is opt-in, capped lower and falls back to
|
||||
/// FC16, so it never changes the answer. Input
|
||||
/// regions are read-only. A string-only tag (an <c>addressString</c> with no keys) is resolved by the
|
||||
/// driver with ITS family through a parser this assembly cannot reference, so it cannot be proven
|
||||
/// writable here and stays read-only.</item>
|
||||
/// <item><b>S7</b> — <c>dataType</c> Byte / Int16 / UInt16 / Int32 / UInt32 / Float32 (one contiguous
|
||||
/// block, <c>EncodeArrayBlock</c>). Bool arrays would clobber neighbouring bits; the wide / String /
|
||||
/// DateTime element types have no array encoder.</item>
|
||||
@@ -61,9 +74,12 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// <para><b>Elements convert; they are not exact-matched.</b> ScadaBridge writes CLR arrays such as
|
||||
/// <c>int[]</c> / <c>List<int></c> to Int16 nodes; the node manager converts a numeric array to the
|
||||
/// node's element type, and the AbCip, AbLegacy and TwinCAT encoders then <c>Convert</c> each element to the
|
||||
/// controller's type (an element that does not fit is <c>BadOutOfRange</c>). So those rows do not key on an
|
||||
/// exact node <c>DataType</c>; they leave out only the node types whose elements NEVER convert: a
|
||||
/// <c>DateTime</c> or <c>String</c> node over a numeric element (AbCip, AbLegacy, TwinCAT — TwinCAT keeps
|
||||
/// controller's type (an element that does not fit is <c>BadOutOfRange</c>); Modbus <c>Convert</c>s each element to
|
||||
/// the register / coil type the same way. So those rows do not key on an exact node <c>DataType</c>; they leave
|
||||
/// out only the node types whose elements the server cannot promise to convert: a <c>DateTime</c> node over a
|
||||
/// numeric element (it never converts) or a <c>String</c> node over one (it converts only when the text happens
|
||||
/// to parse, <c>"1"</c> / <c>"true"</c>) — on Modbus over a numeric or bit element, and a Modbus String element
|
||||
/// takes any node type; likewise on AbCip, AbLegacy and TwinCAT — TwinCAT keeps
|
||||
/// DATE / DT and WSTRING under a DateTime node, and STRING / WSTRING under a String node; a DateTime's text
|
||||
/// form is culture-dependent, so a STRING under a DateTime node is left out), and a <c>Boolean</c> node on
|
||||
/// AbLegacy, whose encoder refuses bool elements. An unknown DataType name (served as BaseDataType) hands the
|
||||
@@ -74,13 +90,26 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
public static class DriverArrayWriteSupport
|
||||
{
|
||||
/// <summary>The Modbus driver's FC16 write cap when <c>maxRegistersPerWrite</c> is absent or <c>0</c>
|
||||
/// (<c>ModbusDriver</c> resolves it the same way).</summary>
|
||||
/// (<c>ModbusDriver</c> resolves it the same way). A configured value only ever LOWERS it — see
|
||||
/// <see cref="ModbusProtocolMaxRegistersPerWrite"/>.</summary>
|
||||
public const int ModbusDefaultMaxRegistersPerWrite = 123;
|
||||
|
||||
/// <summary>Modbus FC15's protocol ceiling on coils per request (0x07B0). The driver sends one FC15 for the
|
||||
/// whole array without chunking, so a longer coil array is refused by every conformant device.</summary>
|
||||
public const int ModbusMaxCoilsPerWrite = 1968;
|
||||
|
||||
/// <summary>Modbus FC16's protocol ceiling on registers per request (0x7B). A configured
|
||||
/// <c>maxRegistersPerWrite</c> above it never lifts it: a conformant device refuses a longer FC16.</summary>
|
||||
public const int ModbusProtocolMaxRegistersPerWrite = 123;
|
||||
|
||||
/// <summary>Modbus FC03's protocol ceiling on registers per request (0x7D) — the bound on a BitInRegister[]
|
||||
/// write's read-modify-write read, together with a configured <c>maxRegistersPerRead</c>.</summary>
|
||||
public const int ModbusProtocolMaxRegistersPerRead = 125;
|
||||
|
||||
/// <summary>The last Modbus address (registers and coils alike): a span ending past it is refused by every
|
||||
/// conformant device (exception 02).</summary>
|
||||
private const long ModbusLastAddress = ushort.MaxValue;
|
||||
|
||||
// Element type → registers per element (ModbusDriver.RegisterCount).
|
||||
private static readonly Dictionary<string, int> ModbusRegisterArrayTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
@@ -89,6 +118,30 @@ public static class DriverArrayWriteSupport
|
||||
["Int64"] = 4, ["UInt64"] = 4, ["Float64"] = 4,
|
||||
};
|
||||
|
||||
// The Modbus enums' member names (ModbusRegion / ModbusDataType / ModbusByteOrder / ModbusStringByteOrder in
|
||||
// Driver.Modbus.Addressing, which this assembly cannot reference). The parity tests enumerate the real enums, so a
|
||||
// member added there without being added here fails a test.
|
||||
private static readonly HashSet<string> ModbusRegions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Coils", "DiscreteInputs", "InputRegisters", "HoldingRegisters",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> ModbusDataTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Bool", "Int16", "UInt16", "Int32", "UInt32", "Int64", "UInt64", "Float32", "Float64",
|
||||
"BitInRegister", "String", "Bcd16", "Bcd32",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> ModbusByteOrders = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"BigEndian", "WordSwap", "ByteSwap", "FullReverse",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> ModbusStringByteOrders = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"HighByteFirst", "LowByteFirst",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> S7ArrayTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Byte", "Int16", "UInt16", "Int32", "UInt32", "Float32",
|
||||
@@ -148,22 +201,7 @@ public static class DriverArrayWriteSupport
|
||||
return dataType is not null && GalaxyArrayDataTypes.Contains(dataType)
|
||||
&& TryParseObject(tagConfig, out var galaxyRoot) && ReadArrayLength(galaxyRoot) is not null;
|
||||
case DriverTypeNames.Modbus:
|
||||
{
|
||||
// Same reads as ModbusTagDefinitionFactory: a blob with no `region` or no numeric `address` is not
|
||||
// a Modbus tag at all (the factory refuses it). A present non-string region is read as absent ⇒
|
||||
// HoldingRegisters, and an absent dataType ⇒ Int16 (TagConfigJson.TryReadEnumStrict).
|
||||
if (!TryParseObject(tagConfig, out var root)
|
||||
|| !root.TryGetProperty("region", out _)
|
||||
|| !root.TryGetProperty("address", out var address) || address.ValueKind != JsonValueKind.Number
|
||||
|| ReadArrayLength(root) is not { } count)
|
||||
return false;
|
||||
var region = ReadEnumName(root, "region") ?? "HoldingRegisters";
|
||||
var native = ReadEnumName(root, "dataType") ?? "Int16";
|
||||
if (string.Equals(region, "Coils", StringComparison.OrdinalIgnoreCase)) return count <= ModbusMaxCoilsPerWrite;
|
||||
return string.Equals(region, "HoldingRegisters", StringComparison.OrdinalIgnoreCase)
|
||||
&& ModbusRegisterArrayTypes.TryGetValue(native, out var registersPerElement)
|
||||
&& (long)count * registersPerElement <= ModbusMaxRegistersPerWrite(driverConfigs);
|
||||
}
|
||||
return ModbusSupports(dataType, tagConfig, driverConfigs);
|
||||
case DriverTypeNames.S7:
|
||||
{
|
||||
// S7TagDefinitionFactory requires a dataType key (absent ⇒ not an S7 tag at all); a present
|
||||
@@ -185,6 +223,81 @@ public static class DriverArrayWriteSupport
|
||||
}
|
||||
}
|
||||
|
||||
// ModbusTagAddressResolution + ModbusTagDefinitionFactory + ModbusDriver.WriteAsync, KEYS shape only:
|
||||
// - `region` AND `address` both present (Classify == Keys). Anything else is a string-only (`addressString`) blob the
|
||||
// driver resolves with ITS family through ModbusAddressParser, which this assembly cannot reference — so it is
|
||||
// never proven writable here and stays read-only (conservative: a hidden write, never an advertised refusal).
|
||||
// - the strict key reads: `address` an integer 0–65535; a present string `region` / `dataType` / `byteOrder` /
|
||||
// `stringByteOrder` must be a member name (a non-string token is read as absent ⇒ the default).
|
||||
// - Coils: the resolver COERCES any type to Bool (never refuses); one FC15 of at most 1968 coils.
|
||||
// - HoldingRegisters: never Bool; fixed-width numeric, String ((stringLength+1)/2 registers, stringLength >= 1) or
|
||||
// BitInRegister (one register each, bitIndex 0–15); arrayLength × registers per element within
|
||||
// maxRegistersPerWrite clamped to FC16's 123 (the driver refuses rather than chunks; FC23 is opt-in and capped
|
||||
// lower, falling back to FC16, so it never changes the answer). BitInRegister[] also READS its span first in one
|
||||
// unchunked FC03, so it is bounded by maxRegistersPerRead (clamped to FC03's 125) too.
|
||||
// - Every span ends at or before address 65535 (a conformant device refuses one past it with exception 02).
|
||||
// - Elements CONVERT, so the node type is not exact-matched. A DateTime node over a numeric or bit element never
|
||||
// converts; a String node over one converts only when the text happens to parse ("1", "true"), which the server
|
||||
// cannot promise — both are left out. A String element Convert.ToString()s any node type.
|
||||
private static bool ModbusSupports(string? dataType, string? tagConfig, string?[]? driverConfigs)
|
||||
{
|
||||
if (!TryParseObject(tagConfig, out var root)
|
||||
|| !root.TryGetProperty("region", out _)
|
||||
|| !root.TryGetProperty("address", out var addressEl) || addressEl.ValueKind != JsonValueKind.Number
|
||||
|| !addressEl.TryGetInt32(out var address) || address is < 0 or > ushort.MaxValue
|
||||
|| ReadArrayLength(root) is not { } count
|
||||
|| !TryReadModbusEnum(root, "region", ModbusRegions, out var region)
|
||||
|| !TryReadModbusEnum(root, "dataType", ModbusDataTypes, out var native)
|
||||
|| !TryReadModbusEnum(root, "byteOrder", ModbusByteOrders, out _)
|
||||
|| !TryReadModbusEnum(root, "stringByteOrder", ModbusStringByteOrders, out _))
|
||||
return false;
|
||||
region ??= "HoldingRegisters";
|
||||
|
||||
if (region.Equals("Coils", StringComparison.OrdinalIgnoreCase))
|
||||
return count <= ModbusMaxCoilsPerWrite && address + (long)count - 1 <= ModbusLastAddress
|
||||
&& dataType is not ("String" or "DateTime");
|
||||
if (!region.Equals("HoldingRegisters", StringComparison.OrdinalIgnoreCase)) return false; // input regions are read-only
|
||||
|
||||
native ??= "Int16";
|
||||
int registersPerElement;
|
||||
if (native.Equals("String", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// The factory narrows to ushort ((ushort)ReadInt) and refuses a zero length.
|
||||
var stringLength = (ushort)ReadInt(root, "stringLength");
|
||||
if (stringLength < 1) return false;
|
||||
registersPerElement = (stringLength + 1) / 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (dataType is "String" or "DateTime") return false;
|
||||
if (native.Equals("BitInRegister", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// The factory narrows to byte ((byte)ReadInt) and refuses a bit above 15. The RMW reads the whole span
|
||||
// in ONE unchunked FC03 before writing it, so the read cap bounds it too.
|
||||
if ((byte)ReadInt(root, "bitIndex") > 15 || count > ModbusMaxRegistersPerRead(driverConfigs)) return false;
|
||||
registersPerElement = 1;
|
||||
}
|
||||
else if (!ModbusRegisterArrayTypes.TryGetValue(native, out registersPerElement))
|
||||
{
|
||||
return false; // Bool in a register region: the factory refuses the pair
|
||||
}
|
||||
}
|
||||
var registers = (long)count * registersPerElement;
|
||||
return registers <= ModbusMaxRegistersPerWrite(driverConfigs) && address + registers - 1 <= ModbusLastAddress;
|
||||
}
|
||||
|
||||
// TagConfigJson.TryReadEnumStrict over the Modbus enum's member names: absent or a non-string token ⇒ null (the
|
||||
// caller applies the default); a string must name a member (trimmed, case-insensitive), else the tag is refused.
|
||||
// Numeric spellings are not member names, so they are refused exactly as ClassifyEnumText refuses them.
|
||||
private static bool TryReadModbusEnum(JsonElement root, string name, HashSet<string> members, out string? value)
|
||||
{
|
||||
value = ReadEnumName(root, name)?.Trim();
|
||||
return value is null || members.Contains(value);
|
||||
}
|
||||
|
||||
private static int ReadInt(JsonElement root, string name) =>
|
||||
root.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number && el.TryGetInt32(out var v) ? v : 0;
|
||||
|
||||
// AbCipTagDefinitionFactory + AbCipDriver.WriteAsync: a non-blank string tagPath, an atomic element type the codec
|
||||
// has a width for (BOOL arrays are bit-packed DWORDs, STRING / structure elements are structures), a declared
|
||||
// length, and not a safety tag (refused BadNotWritable). Each element is CONVERTED, so any numeric / Boolean node
|
||||
@@ -338,9 +451,24 @@ public static class DriverArrayWriteSupport
|
||||
public static bool RefusesArrayValueOnScalarTag(string? driverType) =>
|
||||
driverType is DriverTypeNames.Modbus or DriverTypeNames.S7;
|
||||
|
||||
// The smallest Modbus write cap configured in any config; none configured (absent / non-positive) ⇒ the driver
|
||||
// default (123). The key is matched case-insensitively, like the driver's DTO binding.
|
||||
private static int ModbusMaxRegistersPerWrite(string?[]? driverConfigs)
|
||||
// The effective Modbus FC16 write cap: the smallest maxRegistersPerWrite configured in any config (absent /
|
||||
// non-positive ⇒ the driver default, 123), CLAMPED to FC16's protocol ceiling of 123. The driver trusts a larger
|
||||
// configured cap and sends the over-ceiling FC16 (past 127 registers its one-byte byte-count field even wraps), which
|
||||
// every conformant device refuses — so such a write always fails and is never advertised.
|
||||
private static int ModbusMaxRegistersPerWrite(string?[]? driverConfigs) =>
|
||||
Math.Min(SmallestPositive(driverConfigs, "maxRegistersPerWrite") ?? ModbusDefaultMaxRegistersPerWrite,
|
||||
ModbusProtocolMaxRegistersPerWrite);
|
||||
|
||||
// The effective Modbus FC03 read cap: the smallest maxRegistersPerRead configured (absent / non-positive ⇒ the driver
|
||||
// default, 125), clamped to FC03's protocol ceiling of 125. Only the BitInRegister[] write reads (its RMW issues one
|
||||
// unchunked FC03 of the whole span), so only that shape is bounded by it.
|
||||
private static int ModbusMaxRegistersPerRead(string?[]? driverConfigs) =>
|
||||
Math.Min(SmallestPositive(driverConfigs, "maxRegistersPerRead") ?? ModbusProtocolMaxRegistersPerRead,
|
||||
ModbusProtocolMaxRegistersPerRead);
|
||||
|
||||
// The smallest positive Int32 value of `key` (matched case-insensitively, like the driver's DTO binding) at the root
|
||||
// of any config; null when none carries one.
|
||||
private static int? SmallestPositive(string?[]? driverConfigs, string key)
|
||||
{
|
||||
int? cap = null;
|
||||
foreach (var config in driverConfigs ?? [])
|
||||
@@ -348,7 +476,7 @@ public static class DriverArrayWriteSupport
|
||||
if (!TryParseObject(config, out var root)) continue;
|
||||
foreach (var prop in root.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(prop.Name, "maxRegistersPerWrite", StringComparison.OrdinalIgnoreCase)
|
||||
if (string.Equals(prop.Name, key, StringComparison.OrdinalIgnoreCase)
|
||||
&& prop.Value.ValueKind == JsonValueKind.Number
|
||||
&& prop.Value.TryGetInt32(out var configured) && configured > 0)
|
||||
{
|
||||
@@ -356,7 +484,7 @@ public static class DriverArrayWriteSupport
|
||||
}
|
||||
}
|
||||
}
|
||||
return cap ?? ModbusDefaultMaxRegistersPerWrite;
|
||||
return cap;
|
||||
}
|
||||
|
||||
// The declared element count: isArray is the JSON literal true AND arrayLength is an Int32 >= 1 — the rule the
|
||||
|
||||
@@ -1603,7 +1603,8 @@ public sealed class ModbusDriver
|
||||
for (var i = 0; i < values.Length; i++)
|
||||
if (values[i]) bitmap[i / 8] |= (byte)(1 << (i % 8));
|
||||
var qty = (ushort)values.Length;
|
||||
var pdu15 = new byte[6 + 1 + byteCount];
|
||||
// [fc][addr:2][qty:2][byteCount] + data: exactly 6 + byteCount (no trailing byte past the declared data).
|
||||
var pdu15 = new byte[6 + byteCount];
|
||||
pdu15[0] = 0x0F;
|
||||
pdu15[1] = (byte)(tag.Address >> 8); pdu15[2] = (byte)(tag.Address & 0xFF);
|
||||
pdu15[3] = (byte)(qty >> 8); pdu15[4] = (byte)(qty & 0xFF);
|
||||
@@ -1638,7 +1639,8 @@ public sealed class ModbusDriver
|
||||
$"Write of {qty} registers to {tag.Name} exceeds MaxRegistersPerWrite={writeCap}. " +
|
||||
$"Split the tag (e.g. shorter StringLength or smaller ArrayCount) — partial FC16 chunks would lose atomicity.");
|
||||
if (await TryWriteViaFc23Async(transport, tag, bytes, qty, ct).ConfigureAwait(false)) return;
|
||||
var pdu = new byte[6 + 1 + bytes.Length];
|
||||
// [fc][addr:2][qty:2][byteCount] + data: exactly 6 + byteCount.
|
||||
var pdu = new byte[6 + bytes.Length];
|
||||
pdu[0] = 0x10;
|
||||
pdu[1] = (byte)(tag.Address >> 8); pdu[2] = (byte)(tag.Address & 0xFF);
|
||||
pdu[3] = (byte)(qty >> 8); pdu[4] = (byte)(qty & 0xFF);
|
||||
@@ -1685,7 +1687,8 @@ public sealed class ModbusDriver
|
||||
if (!_options.UseFC23ForMultiRegisterWrites || _options.DisableFC23
|
||||
|| qty < 2 || qty > Fc23MaxWriteRegisters || unsupported.ContainsKey(unit)) return false;
|
||||
|
||||
var pdu = new byte[10 + 1 + bytes.Length];
|
||||
// [fc][readAddr:2][readQty:2][writeAddr:2][writeQty:2][writeByteCount] + data: exactly 10 + byteCount.
|
||||
var pdu = new byte[10 + bytes.Length];
|
||||
pdu[0] = 0x17;
|
||||
pdu[1] = (byte)(tag.Address >> 8); pdu[2] = (byte)(tag.Address & 0xFF); // read start
|
||||
pdu[3] = (byte)(qty >> 8); pdu[4] = (byte)(qty & 0xFF); // read quantity
|
||||
@@ -1824,7 +1827,8 @@ public sealed class ModbusDriver
|
||||
payload[i * 2] = (byte)(reg >> 8);
|
||||
payload[i * 2 + 1] = (byte)(reg & 0xFF);
|
||||
}
|
||||
var pdu = new byte[6 + 1 + payload.Length];
|
||||
// [fc][addr:2][qty:2][byteCount] + data: exactly 6 + byteCount.
|
||||
var pdu = new byte[6 + payload.Length];
|
||||
pdu[0] = 0x10;
|
||||
pdu[1] = (byte)(tag.Address >> 8); pdu[2] = (byte)(tag.Address & 0xFF);
|
||||
pdu[3] = (byte)(count >> 8); pdu[4] = (byte)(count & 0xFF);
|
||||
|
||||
Reference in New Issue
Block a user