52708bff52
MAJOR-1: R4's exact element-type match refused ScadaBridge's CLR-list writes (its element types are only Boolean/Int32/Float/Double/String/ DateTime) to every Int16/UInt16/Byte/UInt32/Int64/UInt64 array node and a Double list to a Float array. EvaluateEquipmentWriteStructure now converts a numeric array of another numeric element type element-wise, CHECKED, into the node's element type and hands the converted array back through OnEquipmentTagWrite's ref value, so the node stores and the driver receives the node's own type. Any out-of-range, fractional-to-integer, NaN/Inf-to- integer or Float-overflowing element refuses the whole write BadTypeMismatch (nothing dispatched) - so the UInt32 -> Int32 bit reinterpretation stays closed. Non-numeric mismatches stay refused. The wrong 'no client relies on a looser rule' comment is corrected. MINOR-1: the array-value-to-scalar-node refusal broke writes back to browse-committed array tags (committed scalar, isArray never set) on pass-through drivers. It now applies only when the owning driver's scalar encoder cannot take an array (DriverArrayWriteSupport. RefusesArrayValueOnScalarTag: Modbus, S7), carried RawTagPlan. ScalarRefusesArrayValue / UnsReferenceVariable.BackingScalarRefusesArrayValue -> EnsureVariable(refuseArrayOnScalar) -> a per-node set in the node manager. OpcUaClient/Galaxy/others keep pre-#610 dispatch. Not on the classifier whitelist (driver-type edit rebuilds); a mirror whose flag changes is recreated, not updated in place. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
201 lines
13 KiB
C#
201 lines
13 KiB
C#
using System.Text.Json;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||
|
||
/// <summary>
|
||
/// 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-<c>ReadWrite</c> 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.
|
||
/// <para>
|
||
/// <b>The rule is "never advertise a write the driver will refuse".</b> A pair is listed only when the
|
||
/// driver's <c>IWritable.WriteAsync</c> encodes a whole-array value end to end; a driver that refuses an
|
||
/// array value (a scalar-only encoder, a <c>NotSupportedException</c> guard) is left out so its array
|
||
/// nodes stay read-only and a client write is refused at the node (<c>BadNotWritable</c>) 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
|
||
/// (<c>*ArrayWriteSupportParityTests</c>), so a driver gaining or losing an array encoder fails a test
|
||
/// until this table follows.
|
||
/// </para>
|
||
/// <list type="bullet">
|
||
/// <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 (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 — 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
|
||
{
|
||
/// <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"] = 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)
|
||
{
|
||
"Byte", "Int16", "UInt16", "Int32", "UInt32", "Float32",
|
||
};
|
||
|
||
private static readonly HashSet<string> GalaxyArrayDataTypes = new(StringComparer.Ordinal)
|
||
{
|
||
"Boolean", "Int32", "Int64", "Float", "Double", "String", "DateTime",
|
||
};
|
||
|
||
/// <summary>
|
||
/// Whether a driver of <paramref name="driverType"/> can write a 1-D array tag of the given shape.
|
||
/// </summary>
|
||
/// <param name="driverType">The owning driver instance's <c>DriverType</c> (a <see cref="DriverTypeNames"/>
|
||
/// 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, 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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gitea #610 review (R6 MINOR-1) — 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
|
||
/// (<c>BadTypeMismatch</c>, nothing dispatched) instead of storing it optimistically, dispatching it, and
|
||
/// reverting it when the scalar encoder throws.
|
||
/// <para>
|
||
/// Only Modbus and S7 qualify: their scalar encoders <c>Convert.ToXxx</c> the value, which throws on an
|
||
/// array. Every other driver keeps the pre-#610 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 <c>/raw</c> commit never sets <c>isArray</c>), the driver publishes an array into
|
||
/// it, and writing that array back worked before #610 — <c>OpcUaClient</c> forwards the
|
||
/// <c>Variant</c> unchanged (the upstream server arbitrates shape) and Galaxy's <c>MxValueEncoder</c>
|
||
/// encodes the array. An unknown / null driver type is not listed ⇒ <see langword="false"/>.
|
||
/// </para>
|
||
/// </summary>
|
||
/// <param name="driverType">The owning driver instance's <c>DriverType</c>.</param>
|
||
/// <returns><see langword="true"/> only when the driver's scalar write path cannot take an array value.</returns>
|
||
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;
|
||
}
|