using System.Text.Json; namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; /// /// Gitea #610 — 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. Before #610 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 — region: Coils (FC15, any element, at most 1968 coils) and /// region: HoldingRegisters with a fixed-width numeric dataType (FC16) whose /// arrayLength × registers-per-element fits the driver's maxRegistersPerWrite (default /// 123 — the driver refuses rather than chunks a longer FC16). Bit-in-register, String and /// Bool-in-register arrays have no array encoder; input regions are read-only. /// 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. /// Everything else (AbCip, AbLegacy, TwinCAT, FOCAS, MQTT, MTConnect, Sql, Calculation) — scalar /// write encoders only; array nodes stay read-only. /// /// Modbus, S7 and Galaxy require a declared length — isArray: true AND an integer /// arrayLength >= 1. The Modbus and S7 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. /// 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). 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; // 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, }; 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", }; /// /// 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: { // Same reads as ModbusTagDefinitionFactory: a blob with no `region` or no numeric `address` is not // a Modbus tag at all (the factory refuses it). A present non-string region is read as absent ⇒ // HoldingRegisters, and an absent dataType ⇒ Int16 (TagConfigJson.TryReadEnumStrict). if (!TryParseObject(tagConfig, out var root) || !root.TryGetProperty("region", out _) || !root.TryGetProperty("address", out var address) || address.ValueKind != JsonValueKind.Number || ReadArrayLength(root) is not { } count) return false; var region = ReadEnumName(root, "region") ?? "HoldingRegisters"; var native = ReadEnumName(root, "dataType") ?? "Int16"; if (string.Equals(region, "Coils", StringComparison.OrdinalIgnoreCase)) return count <= ModbusMaxCoilsPerWrite; return string.Equals(region, "HoldingRegisters", StringComparison.OrdinalIgnoreCase) && ModbusRegisterArrayTypes.TryGetValue(native, out var registersPerElement) && (long)count * registersPerElement <= ModbusMaxRegistersPerWrite(driverConfigs); } 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); } default: return false; } } // The smallest Modbus write cap configured in any config; none configured (absent / non-positive) ⇒ the driver // default (123). The key is matched case-insensitively, like the driver's DTO binding. private static int ModbusMaxRegistersPerWrite(string?[]? driverConfigs) { 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, "maxRegistersPerWrite", StringComparison.OrdinalIgnoreCase) && prop.Value.ValueKind == JsonValueKind.Number && prop.Value.TryGetInt32(out var configured) && configured > 0) { cap = Math.Min(cap ?? int.MaxValue, configured); } } } return cap ?? ModbusDefaultMaxRegistersPerWrite; } // 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; }