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 — 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.
/// - 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). So those rows do not key on an
/// exact node DataType; they leave out only the node types whose elements NEVER convert: a
/// DateTime or String node over a numeric element (AbCip, AbLegacy, 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).
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",
};
// 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:
{
// 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);
}
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;
}
}
// 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 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;
}