cf62007678
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
523 lines
34 KiB
C#
523 lines
34 KiB
C#
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 >= 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><file><number>:<word></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 >= 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<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>); 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 > 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;
|
||
}
|