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:
@@ -3,6 +3,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
@@ -137,6 +138,11 @@ public sealed record RawContainerNode(
|
||||
/// referencing equipment. <see cref="Realm"/> is always <see cref="AddressSpaceRealm.Raw"/>.
|
||||
/// A null <see cref="HistorianTagname"/> means the historian tagname defaults to the RawPath (resolved
|
||||
/// later, not here).
|
||||
/// <see cref="ArrayWritable"/> (Gitea #610) is whether the owning driver can write this tag as a whole
|
||||
/// 1-D array (<see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverArrayWriteSupport"/>); always
|
||||
/// <see langword="false"/> for a scalar. The materialised node is writable iff
|
||||
/// <c>Writable && (!IsArray || ArrayWritable)</c>, so an array tag on a driver with no array
|
||||
/// encoder stays read-only exactly as before #610.
|
||||
/// <para><b>ADDING A FIELD IS A FOUR-PLACE CONTRACT — the hand-written equality below does NOT close
|
||||
/// over new members.</b> Because <see cref="ReferencingEquipmentPaths"/> forces a custom
|
||||
/// <c>Equals</c>/<c>GetHashCode</c>, the compiler's positional equality is suppressed, so a field added
|
||||
@@ -163,7 +169,8 @@ public sealed record RawTagPlan(
|
||||
string? HistorianTagname = null,
|
||||
bool IsArray = false,
|
||||
uint? ArrayLength = null,
|
||||
AddressSpaceRealm Realm = AddressSpaceRealm.Raw)
|
||||
AddressSpaceRealm Realm = AddressSpaceRealm.Raw,
|
||||
bool ArrayWritable = false)
|
||||
{
|
||||
/// <inheritdoc />
|
||||
// Structural equality: the auto-generated record equality compares ReferencingEquipmentPaths (an
|
||||
@@ -185,6 +192,7 @@ public sealed record RawTagPlan(
|
||||
IsArray == other.IsArray &&
|
||||
ArrayLength == other.ArrayLength &&
|
||||
Realm == other.Realm &&
|
||||
ArrayWritable == other.ArrayWritable &&
|
||||
ReferencingEquipmentPaths.SequenceEqual(other.ReferencingEquipmentPaths, StringComparer.Ordinal);
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -194,7 +202,7 @@ public sealed record RawTagPlan(
|
||||
hash.Add(TagId); hash.Add(NodeId); hash.Add(ParentNodeId); hash.Add(DriverInstanceId);
|
||||
hash.Add(Name); hash.Add(DataType); hash.Add(Writable); hash.Add(Alarm);
|
||||
hash.Add(IsHistorized); hash.Add(HistorianTagname); hash.Add(IsArray); hash.Add(ArrayLength);
|
||||
hash.Add(Realm);
|
||||
hash.Add(Realm); hash.Add(ArrayWritable);
|
||||
foreach (var p in ReferencingEquipmentPaths) hash.Add(p, StringComparer.Ordinal);
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
@@ -226,7 +234,9 @@ public sealed record RawTagPlan(
|
||||
/// existing machinery repairs it: the applier removes the previous UNS node and
|
||||
/// <see cref="AddressSpaceApplier.MaterialiseUnsReferences"/> recreates it from these values.
|
||||
/// <see cref="DataType"/> / <see cref="Writable"/> were already inherited onto the record and are
|
||||
/// covered by the same mechanism.</para>
|
||||
/// covered by the same mechanism, as is <see cref="BackingArrayWritable"/> (Gitea #610 — the backing
|
||||
/// tag's <see cref="RawTagPlan.ArrayWritable"/>, so a mirror is writable exactly when its raw node
|
||||
/// is).</para>
|
||||
/// <para>Equality is the COMPILER-GENERATED positional-record equality: every member is a
|
||||
/// value/string type (no <c>IReadOnlyList</c> member, unlike <see cref="RawTagPlan"/>), so the four
|
||||
/// new fields are compared by value with no hand-written <c>Equals</c>/<c>GetHashCode</c> needed. If
|
||||
@@ -245,7 +255,8 @@ public sealed record UnsReferenceVariable(
|
||||
string? BackingHistorianTagname = null,
|
||||
bool BackingIsArray = false,
|
||||
uint? BackingArrayLength = null,
|
||||
AddressSpaceRealm Realm = AddressSpaceRealm.Uns);
|
||||
AddressSpaceRealm Realm = AddressSpaceRealm.Uns,
|
||||
bool BackingArrayWritable = false);
|
||||
|
||||
public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName);
|
||||
public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, string DisplayName);
|
||||
@@ -657,7 +668,7 @@ public static class AddressSpaceComposer
|
||||
rawFolders, driverInstances, devices, tagGroups, tags, unsTagReferences,
|
||||
unsAreas, unsLines, equipment, topology);
|
||||
var unsReferenceVariables = BuildUnsReferenceVariables(
|
||||
unsTagReferences, tags, unsAreas, unsLines, equipment, topology);
|
||||
unsTagReferences, tags, unsAreas, unsLines, equipment, topology, driverInstances);
|
||||
|
||||
return new AddressSpaceComposition(areas, lines, nodes, plans, alarms)
|
||||
{
|
||||
@@ -756,6 +767,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 rawTags = new List<RawTagPlan>();
|
||||
foreach (var t in tags ?? Array.Empty<Tag>())
|
||||
@@ -767,6 +779,8 @@ 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 alarm = intent.Alarm is { } a
|
||||
? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva)
|
||||
: null;
|
||||
@@ -785,7 +799,8 @@ public static class AddressSpaceComposer
|
||||
IsHistorized: intent.IsHistorized,
|
||||
HistorianTagname: intent.HistorianTagname,
|
||||
IsArray: intent.IsArray,
|
||||
ArrayLength: intent.ArrayLength));
|
||||
ArrayLength: intent.ArrayLength,
|
||||
ArrayWritable: arrayWritable));
|
||||
}
|
||||
|
||||
containers.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
|
||||
@@ -793,6 +808,13 @@ 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
|
||||
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First().DriverType, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Emit the UNS reference Variables: one per <see cref="UnsTagReference"/> whose backing raw tag +
|
||||
/// equipment path resolve, keyed <c>(realm=Uns, s=Area/Line/Equipment/EffectiveName)</c> and carrying
|
||||
@@ -806,10 +828,12 @@ public static class AddressSpaceComposer
|
||||
IReadOnlyList<UnsArea> unsAreas,
|
||||
IReadOnlyList<UnsLine> unsLines,
|
||||
IReadOnlyList<Equipment> equipment,
|
||||
RawTopology topology)
|
||||
RawTopology topology,
|
||||
IReadOnlyList<DriverInstance> driverInstances)
|
||||
{
|
||||
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)
|
||||
@@ -840,6 +864,11 @@ public static class AddressSpaceComposer
|
||||
// doc-comment; 2026-08-15 surgical-deploys review). Parsed from the SAME TagConfigIntent the raw
|
||||
// tag plan uses, so the two sides can never disagree.
|
||||
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);
|
||||
|
||||
vars.Add(new UnsReferenceVariable(
|
||||
UnsTagReferenceId: r.UnsTagReferenceId,
|
||||
@@ -852,7 +881,8 @@ public static class AddressSpaceComposer
|
||||
BackingIsHistorized: intent.IsHistorized,
|
||||
BackingHistorianTagname: intent.HistorianTagname,
|
||||
BackingIsArray: intent.IsArray,
|
||||
BackingArrayLength: intent.ArrayLength));
|
||||
BackingArrayLength: intent.ArrayLength,
|
||||
BackingArrayWritable: backingArrayWritable));
|
||||
}
|
||||
|
||||
vars.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
|
||||
|
||||
Reference in New Issue
Block a user