Files
lmxopcua/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverArrayWriteSupport.cs
T
Joseph Doherty cf62007678 fix(arrays): Modbus row honours the FC16/FC03 protocol ceilings, the RMW read cap and the address-space end
Review r0 of Integration-W3:
- I1: the effective FC16 cap is min(configured maxRegistersPerWrite, 123). The
  driver trusts a larger configured cap and sends an over-ceiling FC16 (past 127
  registers its byte-count byte wraps), which every conformant device refuses, so
  such an array is never advertised writable.
- I2: a BitInRegister[] write reads its span first in one unchunked FC03, so it is
  also bounded by min(configured maxRegistersPerRead, 125) (absent/0 = 125).
- m2: every span (registers and coils) must end at or before address 65535.
- m1: the String-node wording says it converts only when the text parses, which
  the server cannot promise (DateTime never converts).
- m4: the parity device now enforces FC01/02 2000, FC03/04 125 (or a smaller
  device read limit), FC15 1968, FC16 123, FC23 121/125, the FC15/FC16 byte-count
  field (exception 03) and the address-space end (exception 02).

Tests: parity rows for each ceiling/cap/span boundary against the real write
path, and Core.Abstractions row tests. The configured-cap test that asserted a
cap of 200 lifts the ceiling now asserts the opposite.

Refs #741

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 17:32:57 -04:00

523 lines
34 KiB
C#
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text.Json;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// the single authority on whether a driver can WRITE a 1-D array tag. The address-space
/// composer consults it at compose time (both compose seams run the same composer, so the live-edit and
/// artifact-decode sides cannot disagree) to decide whether an authored-<c>ReadWrite</c> array tag
/// materialises as a writable OPC UA node. Previously every array node was forced read-only regardless of
/// driver; that remains the answer for every (driver, element type) pair NOT listed here.
/// <para>
/// <b>The rule is "never advertise a write the driver will refuse".</b> A pair is listed only when the
/// driver's <c>IWritable.WriteAsync</c> encodes a whole-array value end to end; a driver that refuses an
/// array value (a scalar-only encoder, a <c>NotSupportedException</c> guard) is left out so its array
/// nodes stay read-only and a client write is refused at the node (<c>BadNotWritable</c>) rather than
/// optimistically applied and reverted. The table is pure data, keyed off the same TagConfig keys the
/// drivers' own tag-definition factories read, because the composer has no reference to driver
/// assemblies. Each listed driver's test project pins its row against the real encoder
/// (<c>*ArrayWriteSupportParityTests</c>), so a driver gaining or losing an array encoder fails a test
/// until this table follows.
/// </para>
/// <list type="bullet">
/// <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 &gt;= 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>
/// <item><b>OpcUaClient</b> — every element type: the value is forwarded to the upstream server as a
/// <c>Variant</c> unchanged, and the upstream server is the arbiter (of shape, too).</item>
/// <item><b>Galaxy</b> — the array variants <c>MxValueEncoder</c> encodes, keyed by the OPC UA
/// <c>DataType</c> the node is created with: Boolean / Int32 / Int64 / Float / Double / String /
/// DateTime — and only with a declared <c>arrayLength</c>: an undeclared length materialises an
/// unbounded node, and a short whole-array write to it would RESIZE the Galaxy attribute (the
/// mxaccessgw <c>gateway.md</c> contract), which is never what a client meant.</item>
/// <item><b>AbCip</b> — <c>dataType</c> SInt / USInt / Int / UInt / DInt / UDInt / Real / Dt / LInt / ULInt /
/// LReal (<c>AbCipArrayCodec</c>, both transports), and not a <c>safetyTag</c>. BOOL arrays (bit-packed
/// DWORDs), STRING and structure arrays have no array encoder.</item>
/// <item><b>AbLegacy</b> — <c>dataType</c> Int / AnalogInt on an N / B / A file, Long on an L file, Float
/// on an F file (the encode width is the DATA FILE's; a type that does not match its file is refused),
/// on a plain <c>&lt;file&gt;&lt;number&gt;:&lt;word&gt;</c> address of at most 256 elements (the PCCC
/// file ceiling the factory clamps to). Refused and so left out: Bit arrays (bit-packed words), String /
/// Timer / Counter / Control arrays (structured; the factory rejects them), <c>/bit</c> and <c>.sub</c>
/// addresses, a PLC-5 device on the native transport (every PLC-5 write is refused before the wire), and
/// the <c>S:</c> / <c>I:</c> / <c>O:</c> files (libplctag refuses their translated names). Indirect
/// addresses (<c>N7:[N7:0]</c>) are kept read-only deliberately: the element moves with the index word
/// and the index read is not atomic with the write.</item>
/// <item><b>TwinCAT</b> — every element type <c>TwinCATArrayCodec</c> lays out (all but Structure, which
/// the factory refuses), STRING / WSTRING included, except a BOOL bit-in-word path
/// (<c>MAIN.Word.3</c>), which is a scalar bit write. A multi-dimensional PLC array is written row-major
/// from the flat 1-D value. The PLC-side shape (element width, declared length ≤ the PLC's) is checked
/// against the resolved symbol at write time, which no compose-time table can know.</item>
/// <item>Everything else (FOCAS, MQTT, MTConnect, Sql, Calculation) — scalar write encoders only; array
/// nodes stay read-only.</item>
/// </list>
/// <para>Modbus, S7, Galaxy, AbCip, AbLegacy and TwinCAT require a declared length — <c>isArray: true</c> AND
/// an integer <c>arrayLength &gt;= 1</c>. Their tag factories treat anything else as a SCALAR tag (whose
/// scalar encoder cannot take an array value), though the composer still materialises an array node.</para>
/// <para><b>Elements convert; they are not exact-matched.</b> ScadaBridge writes CLR arrays such as
/// <c>int[]</c> / <c>List&lt;int&gt;</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>); 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
/// driver whatever the client sent and is not refused.</para>
/// <para>Multi-dimensional arrays (<c>ValueRank &gt; 1</c>) are out of scope altogether — every array node
/// is materialised 1-D (<c>ValueRank = OneDimension</c>), so nothing here considers more than one rank.</para>
/// </summary>
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). 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)
{
["Int16"] = 1, ["UInt16"] = 1, ["Bcd16"] = 1,
["Int32"] = 2, ["UInt32"] = 2, ["Bcd32"] = 2, ["Float32"] = 2,
["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",
};
private static readonly HashSet<string> GalaxyArrayDataTypes = new(StringComparer.Ordinal)
{
"Boolean", "Int32", "Int64", "Float", "Double", "String", "DateTime",
};
// AbCipArrayCodec.ElementWidth: the atomic element types with a fixed wire width.
private static readonly HashSet<string> AbCipArrayTypes = new(StringComparer.OrdinalIgnoreCase)
{
"SInt", "USInt", "Int", "UInt", "DInt", "UDInt", "Real", "Dt", "LInt", "ULInt", "LReal",
};
/// <summary>The PCCC data-file element count ceiling (<c>AbLegacyArray.MaxElements</c>): the AbLegacy tag
/// factory CLAMPS a longer <c>arrayLength</c> to it, so a node of the authored (longer) length never matches.</summary>
public const int AbLegacyMaxArrayElements = 256;
// AbLegacyTagDefinitionFactory.ArrayFileElementType for the files the table admits, keyed by file letter, to the
// authored element types that match that file's width (ArrayFileMismatch). I / O / S are deliberately absent:
// libplctag refuses a write to their translated names, and the table does not widen output-image / status-file
// writes on the native transport alone.
private static readonly Dictionary<string, string[]> AbLegacyFileElementTypes = new(StringComparer.OrdinalIgnoreCase)
{
["N"] = ["Int", "AnalogInt"], ["B"] = ["Int", "AnalogInt"], ["A"] = ["Int", "AnalogInt"],
["L"] = ["Long"],
["F"] = ["Float"],
};
// TwinCATArrayCodec.Encode: every TwinCATDataType but Structure (which the factory refuses outright).
private static readonly HashSet<string> TwinCATArrayTypes = new(StringComparer.OrdinalIgnoreCase)
{
"Bool", "SInt", "USInt", "Int", "UInt", "DInt", "UDInt", "LInt", "ULInt", "Real", "LReal",
"String", "WString", "Time", "Date", "DateTime", "TimeOfDay",
};
/// <summary>
/// Whether a driver of <paramref name="driverType"/> can write a 1-D array tag of the given shape.
/// </summary>
/// <param name="driverType">The owning driver instance's <c>DriverType</c> (a <see cref="DriverTypeNames"/>
/// constant); <see langword="null"/> or unknown ⇒ <see langword="false"/>.</param>
/// <param name="dataType">The tag's OPC UA <c>DataType</c> name (<c>Tag.DataType</c>).</param>
/// <param name="tagConfig">The tag's <c>TagConfig</c> JSON (driver-native address + element type).</param>
/// <param name="driverConfigs">The <c>DriverConfig</c> / <c>DeviceConfig</c> JSON blobs that can carry a
/// driver write cap (Modbus <c>maxRegistersPerWrite</c>). Every one is consulted and the SMALLEST cap
/// wins, so the answer stays conservative however the driver's config merge resolves them.</param>
/// <returns><see langword="true"/> only when the driver encodes a whole-array write for this tag.</returns>
public static bool Supports(string? driverType, string? dataType, string? tagConfig, params string?[] driverConfigs)
{
switch (driverType)
{
case DriverTypeNames.OpcUaClient:
return true;
case DriverTypeNames.Galaxy:
return dataType is not null && GalaxyArrayDataTypes.Contains(dataType)
&& TryParseObject(tagConfig, out var galaxyRoot) && ReadArrayLength(galaxyRoot) is not null;
case DriverTypeNames.Modbus:
return ModbusSupports(dataType, tagConfig, driverConfigs);
case DriverTypeNames.S7:
{
// S7TagDefinitionFactory requires a dataType key (absent ⇒ not an S7 tag at all); a present
// non-string value falls back to Int16, exactly as the factory's strict enum read does.
if (!TryParseObject(tagConfig, out var root) || !root.TryGetProperty("dataType", out _)
|| ReadArrayLength(root) is null)
return false;
var native = ReadEnumName(root, "dataType") ?? "Int16";
return S7ArrayTypes.Contains(native);
}
case DriverTypeNames.AbCip:
return AbCipSupports(dataType, tagConfig);
case DriverTypeNames.AbLegacy:
return AbLegacySupports(dataType, tagConfig, driverConfigs);
case DriverTypeNames.TwinCAT:
return TwinCATSupports(dataType, tagConfig);
default:
return false;
}
}
// 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
// type works; DateTime and (non-numeric) String elements never convert to a Logix integer or REAL.
private static bool AbCipSupports(string? dataType, string? tagConfig)
{
if (!TryParseObject(tagConfig, out var root) || !HasNonBlankString(root, "tagPath")
|| ReadArrayLength(root) is null
|| (root.TryGetProperty("safetyTag", out var safety) && safety.ValueKind == JsonValueKind.True))
return false;
var native = ReadEnumName(root, "dataType")?.Trim() ?? "DInt";
return AbCipArrayTypes.Contains(native) && dataType is not ("String" or "DateTime");
}
// AbLegacyTagDefinitionFactory + AbLegacyDriver.WriteAsync: a flat Int / AnalogInt / Long / Float array on a plain
// `<file><number>:<word>` address whose data file's element width matches (ArrayFileMismatch); no /bit, no .sub,
// no indirect [..] address; at most 256 elements (the factory clamps a longer length, so the node never matches);
// and not a PLC-5 on the native transport (refused before the wire). Elements CONVERT from any numeric node type;
// bool, string and DateTime elements are refused.
private static bool AbLegacySupports(string? dataType, string? tagConfig, string?[]? driverConfigs)
{
if (!TryParseObject(tagConfig, out var root)
|| !root.TryGetProperty("address", out var addressEl) || addressEl.ValueKind != JsonValueKind.String
|| ReadArrayLength(root) is not { } count || count > AbLegacyMaxArrayElements
|| dataType is "Boolean" or "String" or "DateTime")
return false;
var native = ReadEnumName(root, "dataType")?.Trim() ?? "Int";
if (!TryParsePlainPcccAddress(addressEl.GetString(), out var fileLetter)
|| !AbLegacyFileElementTypes.TryGetValue(fileLetter, out var matching)
|| !matching.Contains(native, StringComparer.OrdinalIgnoreCase))
return false;
return !AbLegacyIsNativePlc5(driverConfigs);
}
// `<letters><digits>:<digits>` and nothing else (trimmed) — what AbLegacyAddress.TryParse yields with no bit
// index, no sub-element and no indirection, for the file letters that carry a file number.
private static bool TryParsePlainPcccAddress(string? address, out string fileLetter)
{
fileLetter = "";
if (string.IsNullOrWhiteSpace(address)) return false;
var src = address.Trim();
var colon = src.IndexOf(':');
if (colon <= 0) return false;
var letterEnd = 0;
while (letterEnd < colon && char.IsAsciiLetter(src[letterEnd])) letterEnd++;
if (letterEnd == 0 || letterEnd == colon) return false; // no letter, or no file number
var fileNumber = src[letterEnd..colon];
var word = src[(colon + 1)..];
if (!fileNumber.All(char.IsAsciiDigit) || !int.TryParse(fileNumber, out _)
|| word.Length == 0 || !word.All(char.IsAsciiDigit) || !int.TryParse(word, out _))
return false;
fileLetter = src[..letterEnd];
return true;
}
// Conservative: ANY config naming the native transport AND ANY config naming a PLC-5 (a root `plcFamily`, as a
// DeviceConfig carries it, or one in a `devices` array, as a DriverConfig carries it). Keys and values are matched
// case-insensitively, like the driver's DTO binding and enum parse. An absent transport is libplctag for every
// family (AbLegacyTransportResolver), which writes PLC-5.
private static bool AbLegacyIsNativePlc5(string?[]? driverConfigs)
{
var native = false;
var plc5 = false;
foreach (var config in driverConfigs ?? [])
{
if (!TryParseObject(config, out var root)) continue;
foreach (var prop in root.EnumerateObject())
{
if (IsNamed(prop, "transport") && IsStringValue(prop.Value, "Native")) native = true;
else if (IsNamed(prop, "plcFamily") && IsStringValue(prop.Value, "Plc5")) plc5 = true;
else if (IsNamed(prop, "devices") && prop.Value.ValueKind == JsonValueKind.Array)
{
foreach (var device in prop.Value.EnumerateArray())
{
if (device.ValueKind != JsonValueKind.Object) continue;
foreach (var dp in device.EnumerateObject())
if (IsNamed(dp, "plcFamily") && IsStringValue(dp.Value, "Plc5")) plc5 = true;
}
}
}
}
return native && plc5;
}
// TwinCATTagDefinitionFactory + TwinCATDriver.WriteAsync: a non-blank string symbolPath, a declared length, any
// element type the codec lays out (not Structure, which the factory refuses), and not a BOOL bit-in-word path
// (`MAIN.Word.3`: a scalar bit write, which an array value cannot feed). Elements CONVERT. A String node only feeds
// a STRING / WSTRING element (anything else would depend on the text happening to parse). A DateTime node only
// feeds a DATE / DT element or a WSTRING: its text form is CULTURE-dependent and may carry characters a STRING's
// strict Windows-1252 encoding refuses (ICU's en-US "12:00:00 PM").
private static bool TwinCATSupports(string? dataType, string? tagConfig)
{
if (!TryParseObject(tagConfig, out var root) || !HasNonBlankString(root, "symbolPath")
|| ReadArrayLength(root) is null)
return false;
var native = ReadEnumName(root, "dataType")?.Trim() ?? "DInt";
if (!TwinCATArrayTypes.Contains(native)) return false;
var isWString = native.Equals("WString", StringComparison.OrdinalIgnoreCase);
var isString = isWString || native.Equals("String", StringComparison.OrdinalIgnoreCase);
var isDate = native.Equals("Date", StringComparison.OrdinalIgnoreCase) || native.Equals("DateTime", StringComparison.OrdinalIgnoreCase);
if (dataType == "String" && !isString) return false;
if (dataType == "DateTime" && !isWString && !isDate) return false;
return !(native.Equals("Bool", StringComparison.OrdinalIgnoreCase)
&& HasTwinCATBitSuffix(root.GetProperty("symbolPath").GetString()!));
}
// TwinCATSymbolPath.TryParse's bit-index rule: a last top-level `.` segment of 0..31 with no `[`, behind at least
// one other segment (a TwinCAT 2 global's leading dot belongs to its root segment).
private static bool HasTwinCATBitSuffix(string symbolPath)
{
var src = symbolPath.Trim();
if (src.StartsWith('.')) src = src[1..];
var depth = 0;
var lastDot = -1;
for (var i = 0; i < src.Length; i++)
{
if (src[i] == '[') depth++;
else if (src[i] == ']') depth--;
else if (src[i] == '.' && depth == 0) lastDot = i;
}
if (lastDot < 0) return false;
var last = src[(lastDot + 1)..];
return int.TryParse(last, out var bit) && bit is >= 0 and <= 31 && !last.Contains('[');
}
private static bool HasNonBlankString(JsonElement root, string name) =>
root.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(el.GetString());
private static bool IsNamed(JsonProperty prop, string name) => string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase);
private static bool IsStringValue(JsonElement el, string value) =>
el.ValueKind == JsonValueKind.String && string.Equals(el.GetString()?.Trim(), value, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Whether a driver of <paramref name="driverType"/> is known to REFUSE an
/// array value written to one of its SCALAR tags, so the node manager may refuse such a write inline
/// (<c>BadTypeMismatch</c>, nothing dispatched) instead of storing it optimistically, dispatching it, and
/// reverting it when the scalar encoder throws.
/// <para>
/// Only Modbus and S7 qualify: their scalar encoders <c>Convert.ToXxx</c> the value, which throws on an
/// array. Every other driver keeps the previous behaviour — the write is dispatched and the driver
/// decides. That matters for the pass-through drivers: a browse-committed array attribute is committed
/// as a SCALAR tag (the <c>/raw</c> commit never sets <c>isArray</c>), the driver publishes an array into
/// it, and writing that array back worked previously — <c>OpcUaClient</c> forwards the
/// <c>Variant</c> unchanged (the upstream server arbitrates shape) and Galaxy's <c>MxValueEncoder</c>
/// encodes the array. An unknown / null driver type is not listed ⇒ <see langword="false"/>.
/// </para>
/// </summary>
/// <param name="driverType">The owning driver instance's <c>DriverType</c>.</param>
/// <returns><see langword="true"/> only when the driver's scalar write path cannot take an array value.</returns>
public static bool RefusesArrayValueOnScalarTag(string? driverType) =>
driverType is DriverTypeNames.Modbus or DriverTypeNames.S7;
// 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 ?? [])
{
if (!TryParseObject(config, out var root)) continue;
foreach (var prop in root.EnumerateObject())
{
if (string.Equals(prop.Name, key, StringComparison.OrdinalIgnoreCase)
&& prop.Value.ValueKind == JsonValueKind.Number
&& prop.Value.TryGetInt32(out var configured) && configured > 0)
{
cap = Math.Min(cap ?? int.MaxValue, configured);
}
}
}
return cap;
}
// The declared element count: isArray is the JSON literal true AND arrayLength is an Int32 >= 1 — the rule the
// Modbus and S7 tag factories apply. Anything else is a scalar to those drivers ⇒ null.
private static int? ReadArrayLength(JsonElement root) =>
root.TryGetProperty("isArray", out var isArray) && isArray.ValueKind == JsonValueKind.True
&& root.TryGetProperty("arrayLength", out var length) && length.ValueKind == JsonValueKind.Number
&& length.TryGetInt32(out var count) && count >= 1
? count
: null;
// Parses the TagConfig root object; a blank / malformed / non-object blob is "no tag" ⇒ false. The clone
// detaches the element from the disposed document.
private static bool TryParseObject(string? tagConfig, out JsonElement root)
{
root = default;
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
try
{
using var doc = JsonDocument.Parse(tagConfig);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return false;
root = doc.RootElement.Clone();
return true;
}
catch (JsonException)
{
return false;
}
}
// An enum-valued field's NAME, or null when absent or not a JSON string — the drivers' strict reads treat a
// non-string token (a numerically-serialised enum) as absent and apply the field's default.
private static string? ReadEnumName(JsonElement root, string name) =>
root.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.String ? el.GetString() : null;
}