fix(address-space): exact element-type check for array writes + array-capability shape edges (#610 review R4)
MAJOR-1: the SDK skips TypeInfo.IsInstanceOfDataType on any node with an OnWriteValue handler (every writable equipment node), and the node manager's fail-fast treated numeric<->numeric as compatible, so a UInt32[] to a Galaxy Int32 array matched MxValueEncoder's int[] arm by CLR covariance and was written bit-reinterpreted with a Good result. EvaluateEquipmentWriteStructure now matches an ARRAY write's element type exactly (Byte[]/ByteString the only equivalence; Variant[]/object[] refused) -> BadTypeMismatch, nothing dispatched. MxValueEncoder's int[]/long[] arms are exact-type guarded too, and the Galaxy parity test's false "the SDK refuses" premise is corrected (the UInt32/UInt64 exclusion is now the encoder's own answer). Scalar writes keep numeric widening by decision: consumers write CLR-typed values (Double to a Float node) and every driver's scalar encoder range-checks. MINOR: Modbus row honours maxRegistersPerWrite (smallest of DriverConfig / the tag's DeviceConfig, default 123) and FC15's 1968-coil ceiling, and requires region + numeric address; Modbus/S7/Galaxy require isArray + integer arrayLength >= 1 (Galaxy: an unbounded node would let a short write resize the attribute); an array value to a scalar node is refused inline. Docs: Uns.md array-write rules + upgrade note (ReadWrite array tags become client-writable on first boot), Modbus.md, Galaxy.md. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -20,28 +20,46 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// until this table follows.
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Modbus</b> — <c>region: Coils</c> (FC15, any element) and <c>region: HoldingRegisters</c>
|
||||
/// with a fixed-width numeric <c>dataType</c> (FC16). Bit-in-register, String and Bool-in-register
|
||||
/// arrays have no array encoder; input regions are read-only.</item>
|
||||
/// <item><b>Modbus</b> — <c>region: Coils</c> (FC15, any element, at most 1968 coils) and
|
||||
/// <c>region: HoldingRegisters</c> with a fixed-width numeric <c>dataType</c> (FC16) 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). Bit-in-register, String and
|
||||
/// Bool-in-register arrays have no array encoder; input regions are 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.</item>
|
||||
/// <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.</item>
|
||||
/// 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>
|
||||
/// </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
|
||||
/// scalar encoder cannot take an array value), though the composer still materialises an array node.</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
|
||||
{
|
||||
private static readonly HashSet<string> ModbusRegisterArrayTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
/// <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).</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;
|
||||
|
||||
// Element type → registers per element (ModbusDriver.RegisterCount).
|
||||
private static readonly Dictionary<string, int> ModbusRegisterArrayTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Int16", "UInt16", "Bcd16", "Int32", "UInt32", "Bcd32", "Float32", "Int64", "UInt64", "Float64",
|
||||
["Int16"] = 1, ["UInt16"] = 1, ["Bcd16"] = 1,
|
||||
["Int32"] = 2, ["UInt32"] = 2, ["Bcd32"] = 2, ["Float32"] = 2,
|
||||
["Int64"] = 4, ["UInt64"] = 4, ["Float64"] = 4,
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> S7ArrayTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
@@ -61,31 +79,43 @@ public static class DriverArrayWriteSupport
|
||||
/// 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)
|
||||
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);
|
||||
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: an absent (or non-string) region defaults to
|
||||
// HoldingRegisters and an absent dataType to Int16 (TagConfigJson.TryReadEnumStrict).
|
||||
if (!TryParseObject(tagConfig, out var root)) return false;
|
||||
// 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 true;
|
||||
if (string.Equals(region, "Coils", StringComparison.OrdinalIgnoreCase)) return count <= ModbusMaxCoilsPerWrite;
|
||||
return string.Equals(region, "HoldingRegisters", StringComparison.OrdinalIgnoreCase)
|
||||
&& ModbusRegisterArrayTypes.Contains(native);
|
||||
&& 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 _)) return false;
|
||||
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);
|
||||
}
|
||||
@@ -94,6 +124,36 @@ public static class DriverArrayWriteSupport
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -39,8 +39,13 @@ internal static class MxValueEncoder
|
||||
case DateTimeOffset dto: return new MxValue { TimestampValue = Timestamp.FromDateTimeOffset(dto) };
|
||||
|
||||
case bool[] arr: return EncodeArray(arr, (mx, vs) => mx.BoolValues = ToBoolArray(vs));
|
||||
case int[] arr: return EncodeArray(arr, (mx, vs) => mx.Int32Values = ToInt32Array(vs));
|
||||
case long[] arr: return EncodeArray(arr, (mx, vs) => mx.Int64Values = ToInt64Array(vs));
|
||||
// Exact-type guards: CLR array covariance makes `uint[] is int[]` and `ulong[] is long[]` true, so
|
||||
// without them an unsigned array would be encoded by REINTERPRETING its bits (uint.MaxValue → -1) —
|
||||
// unlike the range-checked scalar arms above. An unsigned array is refused instead (Gitea #610 review).
|
||||
case int[] arr when arr.GetType() == typeof(int[]):
|
||||
return EncodeArray(arr, (mx, vs) => mx.Int32Values = ToInt32Array(vs));
|
||||
case long[] arr when arr.GetType() == typeof(long[]):
|
||||
return EncodeArray(arr, (mx, vs) => mx.Int64Values = ToInt64Array(vs));
|
||||
case float[] arr: return EncodeArray(arr, (mx, vs) => mx.FloatValues = ToFloatArray(vs));
|
||||
case double[] arr: return EncodeArray(arr, (mx, vs) => mx.DoubleValues = ToDoubleArray(vs));
|
||||
case string[] arr: return EncodeArray(arr, (mx, vs) => mx.StringValues = ToStringArray(vs));
|
||||
|
||||
@@ -668,7 +668,8 @@ public static class AddressSpaceComposer
|
||||
rawFolders, driverInstances, devices, tagGroups, tags, unsTagReferences,
|
||||
unsAreas, unsLines, equipment, topology);
|
||||
var unsReferenceVariables = BuildUnsReferenceVariables(
|
||||
unsTagReferences, tags, unsAreas, unsLines, equipment, topology, driverInstances);
|
||||
unsTagReferences, tags, unsAreas, unsLines, equipment, topology,
|
||||
BuildArrayWriteCapability(driverInstances, devices));
|
||||
|
||||
return new AddressSpaceComposition(areas, lines, nodes, plans, alarms)
|
||||
{
|
||||
@@ -767,7 +768,7 @@ public static class AddressSpaceComposer
|
||||
// TagId → the set of referencing equipment UNS folder paths (Area/Line/Equipment), for the
|
||||
// native-alarm multi-notifier. Distinct + ordinal-sorted for determinism.
|
||||
var equipPathByTag = BuildReferencingEquipmentPathsByTag(unsTagReferences, unsAreas, unsLines, equipment);
|
||||
var driverTypeById = BuildDriverTypeById(driverInstances);
|
||||
var arrayWritableFor = BuildArrayWriteCapability(driverInstances, devices);
|
||||
|
||||
var rawTags = new List<RawTagPlan>();
|
||||
foreach (var t in tags ?? Array.Empty<Tag>())
|
||||
@@ -779,8 +780,7 @@ public static class AddressSpaceComposer
|
||||
: topology.DevicePaths.GetValueOrDefault(t.DeviceId);
|
||||
var driverInstanceId = topology.DriverIdByDevice.GetValueOrDefault(t.DeviceId, string.Empty);
|
||||
var intent = TagConfigIntent.Parse(t.TagConfig);
|
||||
var arrayWritable = intent.IsArray && DriverArrayWriteSupport.Supports(
|
||||
driverTypeById.GetValueOrDefault(driverInstanceId), t.DataType, t.TagConfig);
|
||||
var arrayWritable = intent.IsArray && arrayWritableFor(t);
|
||||
var alarm = intent.Alarm is { } a
|
||||
? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva)
|
||||
: null;
|
||||
@@ -808,12 +808,30 @@ public static class AddressSpaceComposer
|
||||
return (containers, rawTags);
|
||||
}
|
||||
|
||||
/// <summary><c>DriverInstanceId → DriverType</c>, for the per-driver array-write capability (Gitea #610).
|
||||
/// First row wins on a duplicate id, matching the composer's other id maps.</summary>
|
||||
private static IReadOnlyDictionary<string, string> BuildDriverTypeById(IReadOnlyList<DriverInstance> driverInstances) =>
|
||||
driverInstances
|
||||
/// <summary>
|
||||
/// The per-driver array-write capability (Gitea #610) as a per-tag predicate over the owning driver's
|
||||
/// <c>DriverType</c>, the driver's <c>DriverConfig</c> and the tag's own device's <c>DeviceConfig</c>
|
||||
/// (either config can carry a write cap such as Modbus <c>maxRegistersPerWrite</c>; the table takes the
|
||||
/// smaller). First row wins on a duplicate id, matching the composer's other id maps. Built once per
|
||||
/// compose and shared by the raw and UNS passes, so a mirror is writable exactly when its raw node is.
|
||||
/// </summary>
|
||||
private static Func<Tag, bool> BuildArrayWriteCapability(
|
||||
IReadOnlyList<DriverInstance> driverInstances, IReadOnlyList<Device>? devices)
|
||||
{
|
||||
var driverById = driverInstances
|
||||
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First().DriverType, StringComparer.Ordinal);
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
||||
var deviceById = (devices ?? Array.Empty<Device>())
|
||||
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
||||
return tag =>
|
||||
{
|
||||
var device = deviceById.GetValueOrDefault(tag.DeviceId);
|
||||
var driver = device is null ? null : driverById.GetValueOrDefault(device.DriverInstanceId);
|
||||
return DriverArrayWriteSupport.Supports(
|
||||
driver?.DriverType, tag.DataType, tag.TagConfig, driver?.DriverConfig, device?.DeviceConfig);
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emit the UNS reference Variables: one per <see cref="UnsTagReference"/> whose backing raw tag +
|
||||
@@ -829,11 +847,10 @@ public static class AddressSpaceComposer
|
||||
IReadOnlyList<UnsLine> unsLines,
|
||||
IReadOnlyList<Equipment> equipment,
|
||||
RawTopology topology,
|
||||
IReadOnlyList<DriverInstance> driverInstances)
|
||||
Func<Tag, bool> arrayWritableFor)
|
||||
{
|
||||
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
|
||||
if (refs.Count == 0) return Array.Empty<UnsReferenceVariable>();
|
||||
var driverTypeById = BuildDriverTypeById(driverInstances);
|
||||
|
||||
var tagsById = (tags ?? Array.Empty<Tag>())
|
||||
.GroupBy(t => t.TagId, StringComparer.Ordinal)
|
||||
@@ -866,9 +883,7 @@ public static class AddressSpaceComposer
|
||||
var intent = TagConfigIntent.Parse(tag.TagConfig);
|
||||
// Same derivation as BuildRawSubtree's ArrayWritable, off the same row, so a mirror is writable
|
||||
// exactly when its raw node is.
|
||||
var backingArrayWritable = intent.IsArray && DriverArrayWriteSupport.Supports(
|
||||
driverTypeById.GetValueOrDefault(topology.DriverIdByDevice.GetValueOrDefault(tag.DeviceId, string.Empty)),
|
||||
tag.DataType, tag.TagConfig);
|
||||
var backingArrayWritable = intent.IsArray && arrayWritableFor(tag);
|
||||
|
||||
vars.Add(new UnsReferenceVariable(
|
||||
UnsTagReferenceId: r.UnsTagReferenceId,
|
||||
|
||||
@@ -2025,10 +2025,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// conservative — it only rejects when BOTH the expected and actual built-in types are confidently
|
||||
/// resolved AND differ (with numeric widening + the BaseDataType "accept anything" wildcard
|
||||
/// allowed); anything uncertain (a non-variable node, an unresolved/abstract DataType, a
|
||||
/// <see cref="Variant"/>/array payload whose element type isn't cheaply known) is allowed through so
|
||||
/// the SDK's own (authoritative) type coercion in <c>BaseVariableState.WriteValue</c> remains the
|
||||
/// final arbiter. We deliberately do NOT attempt deep array-dimension / structured-type validation
|
||||
/// here — that is left to the SDK.
|
||||
/// <see cref="Variant"/> payload whose type isn't cheaply known) is allowed through.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>⚠️ This is the ONLY type check a writable equipment node gets</b> (Gitea #610 review). The SDK's
|
||||
/// <c>BaseVariableState.WriteValueAttribute</c> runs <c>TypeInfo.IsInstanceOfDataType</c> only when
|
||||
/// the node has NO <c>OnWriteValue</c> handler; with one (every writable equipment node) it checks the
|
||||
/// access level, calls the handler and stores the value as given. So: an ARRAY write is matched
|
||||
/// EXACTLY (shape, declared length and element type — the SDK's own rule, restored), and an array
|
||||
/// value to a scalar node is refused. A SCALAR write keeps the looser numeric rule above on purpose
|
||||
/// (see <see cref="IsBuiltInTypeCompatible"/>).
|
||||
/// </para>
|
||||
/// Pure (no SDK server / Lock needed): reads only <paramref name="value"/> and the node's static
|
||||
/// <c>DataType</c>, so it is unit-testable in isolation.
|
||||
@@ -2049,8 +2055,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// 1-D array of its declared length: every array-capable driver refuses a short or long block (Modbus
|
||||
// FC15/FC16, S7's contiguous block), so a wrong shape is rejected here instead of being optimistically
|
||||
// applied, dispatched, refused and reverted. ArrayDimensions [0] / absent means unbounded ⇒ any length.
|
||||
var isArrayNode = false;
|
||||
if (node is BaseDataVariableState { ValueRank: ValueRanks.OneDimension } arrayNode)
|
||||
{
|
||||
isArrayNode = true;
|
||||
if (value is not Array { Rank: 1 } array)
|
||||
{
|
||||
return new ServiceResult(StatusCodes.BadTypeMismatch, "a 1-D array node takes a 1-D array value");
|
||||
@@ -2071,14 +2079,43 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
var expected = TypeInfo.GetBuiltInType(variable.DataType); // NodeId ⇒ built-in; abstract/unknown ⇒ Null
|
||||
if (expected is BuiltInType.Null or BuiltInType.Variant or BuiltInType.DataValue) return null; // unresolved / wildcard
|
||||
|
||||
// Gitea #610 review — the mirror case: an array value to a SCALAR node of a resolved DataType is refused
|
||||
// up front rather than optimistically stored, dispatched to a scalar encoder, refused and reverted. A
|
||||
// byte[] is exempt: on the wire it is a ByteString, which is a scalar (the type check below still decides
|
||||
// whether the node takes one). An unresolved-DataType node deferred above, as it always has.
|
||||
if (variable.ValueRank == ValueRanks.Scalar && value is Array and not byte[])
|
||||
{
|
||||
return new ServiceResult(StatusCodes.BadTypeMismatch, "a scalar node takes a scalar value");
|
||||
}
|
||||
|
||||
// SAFETY BOUNDARY: only reject against the CLOSED set of built-in types a writable equipment node can
|
||||
// actually carry (per ResolveBuiltInDataType). Any other expected type (Enumeration, Guid, NodeId,
|
||||
// StatusCode, …) is DEFERRED to the SDK's authoritative coercion so this fail-fast can NEVER false-reject
|
||||
// a write the SDK would have accepted (e.g. an Int32 payload to an Enumeration node coerces fine).
|
||||
if (!IsCheckableExpectedType(expected)) return null;
|
||||
|
||||
// TypeInfo.Construct(object) classifies the runtime payload; an unclassifiable value ⇒ Unknown (Null).
|
||||
// TypeInfo.Construct(object) classifies the runtime payload (an array by its ELEMENT type); an
|
||||
// unclassifiable value ⇒ Unknown (Null).
|
||||
var actual = TypeInfo.Construct(value).BuiltInType;
|
||||
|
||||
// Gitea #610 review (MAJOR-1) — an ARRAY write is checked EXACTLY, element type against DataType. The SDK
|
||||
// runs its own TypeInfo.IsInstanceOfDataType check only when a node has NO OnWriteValue handler, and every
|
||||
// writable equipment node has one, so nothing else stands between a mistyped array and the driver: a
|
||||
// UInt32[] to a Galaxy Int32 array matched MxValueEncoder's int[] arm by CLR array covariance and wrote
|
||||
// uint.MaxValue as -1 with a Good result, and a Modbus/S7 node kept serving an int[] under an Int16
|
||||
// DataType. This restores the SDK's own rule for arrays — the element type must match, with the single
|
||||
// Byte-array/ByteString equivalence (a Byte[] decodes as byte[], which classifies as ByteString) — and
|
||||
// an unclassifiable element type (a Variant[] / object[]) is refused rather than deferred. Arrays were
|
||||
// never client-writable before #610, so no client relies on a looser rule.
|
||||
if (isArrayNode)
|
||||
{
|
||||
return actual == expected || (expected, actual) is (BuiltInType.Byte, BuiltInType.ByteString)
|
||||
? null
|
||||
: new ServiceResult(
|
||||
StatusCodes.BadTypeMismatch,
|
||||
$"array element type {actual} does not match node data type {expected}");
|
||||
}
|
||||
|
||||
if (actual == BuiltInType.Null) return null; // couldn't classify the payload ⇒ defer to the SDK
|
||||
|
||||
if (!IsBuiltInTypeCompatible(expected, actual))
|
||||
@@ -2106,9 +2143,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
: new ServiceResult(StatusCodes.BadWriteNotSupported, "IndexRange writes are not supported; write the whole array");
|
||||
|
||||
/// <summary>
|
||||
/// Conservative built-in-type compatibility test for <see cref="EvaluateEquipmentWriteStructure"/>.
|
||||
/// Returns true (compatible — allow through) unless there is a confident mismatch. Exact matches pass;
|
||||
/// numeric-to-numeric is treated as compatible (the SDK widens/narrows numerics); a
|
||||
/// Conservative built-in-type compatibility test for <see cref="EvaluateEquipmentWriteStructure"/>'s
|
||||
/// SCALAR path (array writes are matched exactly — see there). Returns true (compatible — allow through)
|
||||
/// unless there is a confident mismatch. Exact matches pass; numeric-to-numeric is treated as compatible —
|
||||
/// deliberately looser than the SDK, which would refuse it: consumers (ScadaBridge) write CLR-typed values
|
||||
/// such as a Double to a Float node, and every driver's SCALAR encoder converts with a range check
|
||||
/// (<c>Convert.ToInt16</c>, MxValueEncoder's <c>uint ≤ int.MaxValue</c> arm, …), so an out-of-range value
|
||||
/// fails the device write and is reverted rather than silently reinterpreted; a
|
||||
/// <see cref="BuiltInType.ByteString"/>↔<see cref="BuiltInType.Byte"/> array nuance and any
|
||||
/// <see cref="BuiltInType.Variant"/> on either side are treated as compatible. Only a clear cross-family
|
||||
/// mismatch (e.g. writing a String to a Boolean node) returns false. Pure + static.
|
||||
|
||||
Reference in New Issue
Block a user