using System.Text.Json; namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; /// /// 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-ReadWrite 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. /// /// The rule is "never advertise a write the driver will refuse". A pair is listed only when the /// driver's IWritable.WriteAsync encodes a whole-array value end to end; a driver that refuses an /// array value (a scalar-only encoder, a NotSupportedException guard) is left out so its array /// nodes stay read-only and a client write is refused at the node (BadNotWritable) 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 /// (*ArrayWriteSupportParityTests), so a driver gaining or losing an array encoder fails a test /// until this table follows. /// /// /// Modbus — tags addressed by the region + address KEYS (the /// ModbusTagAddressResolution rule: keys win whenever both are present), read as strictly as the /// driver reads them (address an integer 0–65535; a present region / dataType / /// byteOrder / stringByteOrder string must name a member). region: Coils: FC15, at most /// 1968 coils, with ANY dataType — the resolver coerces a bit region to Bool and never refuses it. /// region: HoldingRegisters (FC16), never Bool: a fixed-width numeric dataType, /// String ((stringLength+1)/2 registers per element, stringLength >= 1) or /// BitInRegister (one register per element; bitIndex 0–15), whose /// arrayLength × registers-per-element fits the driver's maxRegistersPerWrite (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 maxRegistersPerRead (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 addressString 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. /// S7 — dataType Byte / Int16 / UInt16 / Int32 / UInt32 / Float32 (one contiguous /// block, EncodeArrayBlock). Bool arrays would clobber neighbouring bits; the wide / String / /// DateTime element types have no array encoder. /// OpcUaClient — every element type: the value is forwarded to the upstream server as a /// Variant unchanged, and the upstream server is the arbiter (of shape, too). /// Galaxy — the array variants MxValueEncoder encodes, keyed by the OPC UA /// DataType the node is created with: Boolean / Int32 / Int64 / Float / Double / String / /// DateTime — and only with a declared arrayLength: an undeclared length materialises an /// unbounded node, and a short whole-array write to it would RESIZE the Galaxy attribute (the /// mxaccessgw gateway.md contract), which is never what a client meant. /// AbCip — dataType SInt / USInt / Int / UInt / DInt / UDInt / Real / Dt / LInt / ULInt / /// LReal (AbCipArrayCodec, both transports), and not a safetyTag. BOOL arrays (bit-packed /// DWORDs), STRING and structure arrays have no array encoder. /// AbLegacy — dataType 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 <file><number>:<word> 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), /bit and .sub /// addresses, a PLC-5 device on the native transport (every PLC-5 write is refused before the wire), and /// the S: / I: / O: files (libplctag refuses their translated names). Indirect /// addresses (N7:[N7:0]) are kept read-only deliberately: the element moves with the index word /// and the index read is not atomic with the write. /// TwinCAT — every element type TwinCATArrayCodec lays out (all but Structure, which /// the factory refuses), STRING / WSTRING included, except a BOOL bit-in-word path /// (MAIN.Word.3), 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. /// Everything else (FOCAS, MQTT, MTConnect, Sql, Calculation) — scalar write encoders only; array /// nodes stay read-only. /// /// Modbus, S7, Galaxy, AbCip, AbLegacy and TwinCAT require a declared length — isArray: true AND /// an integer arrayLength >= 1. 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. /// Elements convert; they are not exact-matched. ScadaBridge writes CLR arrays such as /// int[] / List<int> to Int16 nodes; the node manager converts a numeric array to the /// node's element type, and the AbCip, AbLegacy and TwinCAT encoders then Convert each element to the /// controller's type (an element that does not fit is BadOutOfRange); Modbus Converts each element to /// the register / coil type the same way. So those rows do not key on an exact node DataType; they leave /// out only the node types whose elements the server cannot promise to convert: a DateTime node over a /// numeric element (it never converts) or a String node over one (it converts only when the text happens /// to parse, "1" / "true") — 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 Boolean 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. /// Multi-dimensional arrays (ValueRank > 1) are out of scope altogether — every array node /// is materialised 1-D (ValueRank = OneDimension), so nothing here considers more than one rank. /// public static class DriverArrayWriteSupport { /// The Modbus driver's FC16 write cap when maxRegistersPerWrite is absent or 0 /// (ModbusDriver resolves it the same way). A configured value only ever LOWERS it — see /// . public const int ModbusDefaultMaxRegistersPerWrite = 123; /// 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. public const int ModbusMaxCoilsPerWrite = 1968; /// Modbus FC16's protocol ceiling on registers per request (0x7B). A configured /// maxRegistersPerWrite above it never lifts it: a conformant device refuses a longer FC16. public const int ModbusProtocolMaxRegistersPerWrite = 123; /// 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 maxRegistersPerRead. public const int ModbusProtocolMaxRegistersPerRead = 125; /// The last Modbus address (registers and coils alike): a span ending past it is refused by every /// conformant device (exception 02). private const long ModbusLastAddress = ushort.MaxValue; // Element type → registers per element (ModbusDriver.RegisterCount). private static readonly Dictionary 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 ModbusRegions = new(StringComparer.OrdinalIgnoreCase) { "Coils", "DiscreteInputs", "InputRegisters", "HoldingRegisters", }; private static readonly HashSet ModbusDataTypes = new(StringComparer.OrdinalIgnoreCase) { "Bool", "Int16", "UInt16", "Int32", "UInt32", "Int64", "UInt64", "Float32", "Float64", "BitInRegister", "String", "Bcd16", "Bcd32", }; private static readonly HashSet ModbusByteOrders = new(StringComparer.OrdinalIgnoreCase) { "BigEndian", "WordSwap", "ByteSwap", "FullReverse", }; private static readonly HashSet ModbusStringByteOrders = new(StringComparer.OrdinalIgnoreCase) { "HighByteFirst", "LowByteFirst", }; private static readonly HashSet S7ArrayTypes = new(StringComparer.OrdinalIgnoreCase) { "Byte", "Int16", "UInt16", "Int32", "UInt32", "Float32", }; private static readonly HashSet 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 AbCipArrayTypes = new(StringComparer.OrdinalIgnoreCase) { "SInt", "USInt", "Int", "UInt", "DInt", "UDInt", "Real", "Dt", "LInt", "ULInt", "LReal", }; /// The PCCC data-file element count ceiling (AbLegacyArray.MaxElements): the AbLegacy tag /// factory CLAMPS a longer arrayLength to it, so a node of the authored (longer) length never matches. 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 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 TwinCATArrayTypes = new(StringComparer.OrdinalIgnoreCase) { "Bool", "SInt", "USInt", "Int", "UInt", "DInt", "UDInt", "LInt", "ULInt", "Real", "LReal", "String", "WString", "Time", "Date", "DateTime", "TimeOfDay", }; /// /// Whether a driver of can write a 1-D array tag of the given shape. /// /// The owning driver instance's DriverType (a /// constant); or unknown ⇒ . /// The tag's OPC UA DataType name (Tag.DataType). /// The tag's TagConfig JSON (driver-native address + element type). /// The DriverConfig / DeviceConfig JSON blobs that can carry a /// driver write cap (Modbus maxRegistersPerWrite). Every one is consulted and the SMALLEST cap /// wins, so the answer stays conservative however the driver's config merge resolves them. /// only when the driver encodes a whole-array write for this tag. 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 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 // `:` 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); } // `:` 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); /// /// Whether a driver of is known to REFUSE an /// array value written to one of its SCALAR tags, so the node manager may refuse such a write inline /// (BadTypeMismatch, nothing dispatched) instead of storing it optimistically, dispatching it, and /// reverting it when the scalar encoder throws. /// /// Only Modbus and S7 qualify: their scalar encoders Convert.ToXxx 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 /raw commit never sets isArray), the driver publishes an array into /// it, and writing that array back worked previously — OpcUaClient forwards the /// Variant unchanged (the upstream server arbitrates shape) and Galaxy's MxValueEncoder /// encodes the array. An unknown / null driver type is not listed ⇒ . /// /// /// The owning driver instance's DriverType. /// only when the driver's scalar write path cannot take an array value. 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; }