Merge master d520f77b into remed/lane-E5
Refs #748 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -35,12 +35,39 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// 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>Everything else (AbCip, AbLegacy, TwinCAT, FOCAS, MQTT, MTConnect, Sql, Calculation) — scalar
|
||||
/// write encoders only; array nodes stay read-only.</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 and Galaxy require a declared length — <c>isArray: true</c> AND an integer
|
||||
/// <c>arrayLength >= 1</c>. The Modbus and S7 tag factories treat anything else as a SCALAR tag (whose
|
||||
/// <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>). So those rows do not key on an
|
||||
/// exact node <c>DataType</c>; they leave out only the node types whose elements NEVER convert: a
|
||||
/// <c>DateTime</c> or <c>String</c> 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 <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>
|
||||
@@ -72,6 +99,34 @@ public static class DriverArrayWriteSupport
|
||||
"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>
|
||||
@@ -119,11 +174,150 @@ public static class DriverArrayWriteSupport
|
||||
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
|
||||
// `<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
|
||||
|
||||
Reference in New Issue
Block a user