feat(address-space): server-side array writes gated per driver capability (#610)
Array nodes were forced read-only for every driver. A new single authority, Core.Abstractions DriverArrayWriteSupport, says which (driver, element type) pairs encode a whole-array write: Modbus (Coils / numeric HoldingRegisters), S7 (Byte/Int16/UInt16/Int32/UInt32/Float32), OpcUaClient (pass-through) and Galaxy (Boolean/Int32/Int64/Float/Double/String/DateTime). Everything else stays read-only, so the server never advertises a write the driver refuses. - Composer derives RawTagPlan.ArrayWritable + UnsReferenceVariable .BackingArrayWritable at compose time (both seams, byte-parity); the applier's one writability rule is Writable && (!IsArray || ArrayWritable) for materialise and surgical paths, raw and UNS alike. - ArrayWritable is on the classifier's surgical raw-tag whitelist and mirrored on the UNS record (UnsReferenceBackingParityTests updated). - Node manager refuses a wrong-length / non-array value on an array node (BadTypeMismatch) and any IndexRange write (BadWriteNotSupported) inline, before dispatch; WriteOperate/ACL gates and the revert are unchanged. - Per-driver parity tests pin the table against the real Modbus write path, S7 EncodeArrayBlock and Galaxy MxValueEncoder (UInt32/UInt64 excluded: array covariance reinterprets their bits). - Over-the-wire proof in OpcUaServer.IntegrationTests (ArrayWriteWireTests). - ValueRank > 1 documented as out of scope by decision (docs/Uns.md). Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
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) 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>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>
|
||||
/// <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>
|
||||
/// <item>Everything else (AbCip, AbLegacy, TwinCAT, FOCAS, MQTT, MTConnect, Sql, Calculation) — scalar
|
||||
/// write encoders only; array nodes stay read-only.</item>
|
||||
/// </list>
|
||||
/// <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)
|
||||
{
|
||||
"Int16", "UInt16", "Bcd16", "Int32", "UInt32", "Bcd32", "Float32", "Int64", "UInt64", "Float64",
|
||||
};
|
||||
|
||||
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>
|
||||
/// <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)
|
||||
{
|
||||
switch (driverType)
|
||||
{
|
||||
case DriverTypeNames.OpcUaClient:
|
||||
return true;
|
||||
case DriverTypeNames.Galaxy:
|
||||
return dataType is not null && GalaxyArrayDataTypes.Contains(dataType);
|
||||
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;
|
||||
var region = ReadEnumName(root, "region") ?? "HoldingRegisters";
|
||||
var native = ReadEnumName(root, "dataType") ?? "Int16";
|
||||
if (string.Equals(region, "Coils", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
return string.Equals(region, "HoldingRegisters", StringComparison.OrdinalIgnoreCase)
|
||||
&& ModbusRegisterArrayTypes.Contains(native);
|
||||
}
|
||||
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;
|
||||
var native = ReadEnumName(root, "dataType") ?? "Int16";
|
||||
return S7ArrayTypes.Contains(native);
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user