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:
@@ -229,9 +229,9 @@ public sealed class AddressSpaceApplier
|
||||
}
|
||||
|
||||
// Compute writable + historian EXACTLY as MaterialiseRawSubtree does so the in-place update
|
||||
// matches what a rebuild would have produced: arrays are forced read-only (the driver write
|
||||
// path can't handle them) and a blank historian override falls back to the RawPath (== NodeId).
|
||||
var writable = d.Current.Writable && !d.Current.IsArray;
|
||||
// matches what a rebuild would have produced: an array is writable only when its driver can
|
||||
// write arrays (#610) and a blank historian override falls back to the RawPath (== NodeId).
|
||||
var writable = RawNodeWritable(d.Current);
|
||||
var historian = d.Current.IsHistorized
|
||||
? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.NodeId : d.Current.HistorianTagname)
|
||||
: null;
|
||||
@@ -275,9 +275,9 @@ public sealed class AddressSpaceApplier
|
||||
if (IsIdentityPreservingUnsRefDelta(d))
|
||||
{
|
||||
// Resolve writable + historian EXACTLY as MaterialiseUnsReferences does when it creates
|
||||
// the node: arrays are forced read-only, and a blank override falls back to the backing
|
||||
// RawPath (which IS the backing raw tag's NodeId).
|
||||
var writable = d.Current.Writable && !d.Current.BackingIsArray;
|
||||
// the node: an array is writable only when its backing driver can write arrays (#610), and
|
||||
// a blank override falls back to the backing RawPath (which IS the backing raw tag's NodeId).
|
||||
var writable = UnsReferenceNodeWritable(d.Current);
|
||||
var historian = d.Current.BackingIsHistorized
|
||||
? (string.IsNullOrWhiteSpace(d.Current.BackingHistorianTagname) ? d.Current.BackingRawPath : d.Current.BackingHistorianTagname)
|
||||
: null;
|
||||
@@ -467,6 +467,21 @@ public sealed class AddressSpaceApplier
|
||||
/// </summary>
|
||||
/// <param name="d">The UNS reference delta to classify.</param>
|
||||
/// <returns><c>true</c> when the delta is attribute-only and may be applied in place.</returns>
|
||||
/// <summary>
|
||||
/// Gitea #610 — the ONE writability rule for a raw value node, shared by the materialise pass and the
|
||||
/// surgical in-place update so the two can never disagree: authored <c>ReadWrite</c>, and — for an array —
|
||||
/// a driver that can write the array (<see cref="RawTagPlan.ArrayWritable"/>, from
|
||||
/// <see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverArrayWriteSupport"/>). An array tag on a driver
|
||||
/// with no array encoder stays read-only, so a client write is refused at the node (<c>BadNotWritable</c>)
|
||||
/// instead of being optimistically applied and reverted.
|
||||
/// </summary>
|
||||
internal static bool RawNodeWritable(RawTagPlan t) => t.Writable && (!t.IsArray || t.ArrayWritable);
|
||||
|
||||
/// <summary>The UNS-mirror counterpart of <see cref="RawNodeWritable"/>, off the inherited <c>Backing*</c>
|
||||
/// fields — a mirror is writable exactly when its raw node is.</summary>
|
||||
internal static bool UnsReferenceNodeWritable(UnsReferenceVariable v) =>
|
||||
v.Writable && (!v.BackingIsArray || v.BackingArrayWritable);
|
||||
|
||||
private static bool IsIdentityPreservingUnsRefDelta(AddressSpacePlan.UnsReferenceDelta d) =>
|
||||
string.Equals(d.Previous.NodeId, d.Current.NodeId, StringComparison.Ordinal) &&
|
||||
string.Equals(d.Previous.EquipmentId, d.Current.EquipmentId, StringComparison.Ordinal) &&
|
||||
@@ -972,11 +987,12 @@ public sealed class AddressSpaceApplier
|
||||
else
|
||||
{
|
||||
// A historized tag materialises Historizing + HistoryRead; the effective historian tagname is
|
||||
// the override else the RawPath (== NodeId). Array writes are out of scope → force read-only.
|
||||
// the override else the RawPath (== NodeId). An array is writable only when its driver can
|
||||
// write arrays (#610) — see RawNodeWritable.
|
||||
string? historianTagname = t.IsHistorized
|
||||
? (string.IsNullOrWhiteSpace(t.HistorianTagname) ? t.NodeId : t.HistorianTagname)
|
||||
: null;
|
||||
var writable = t.Writable && !t.IsArray;
|
||||
var writable = RawNodeWritable(t);
|
||||
if (!SafeEnsureVariable(t.NodeId, t.ParentNodeId, t.Name, t.DataType, writable, AddressSpaceRealm.Raw, historianTagname, t.IsArray, t.ArrayLength)) failed++;
|
||||
}
|
||||
}
|
||||
@@ -1033,7 +1049,7 @@ public sealed class AddressSpaceApplier
|
||||
string? historianTagname = v.BackingIsHistorized
|
||||
? (string.IsNullOrWhiteSpace(v.BackingHistorianTagname) ? v.BackingRawPath : v.BackingHistorianTagname)
|
||||
: null;
|
||||
var writable = v.Writable && !isArray;
|
||||
var writable = UnsReferenceNodeWritable(v);
|
||||
if (!SafeEnsureVariable(v.NodeId, v.EquipmentId, v.EffectiveName, v.DataType, writable, AddressSpaceRealm.Uns, historianTagname, isArray, arrayLength))
|
||||
{
|
||||
failed++;
|
||||
@@ -1120,9 +1136,9 @@ public sealed class AddressSpaceApplier
|
||||
string? historianTagname = tag.IsHistorized
|
||||
? (string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname)
|
||||
: null;
|
||||
// Array writes are out of scope (Phase 4c read-only surface): force array tags read-only
|
||||
// even if authored ReadWrite, so a client write cannot reach the driver write path which
|
||||
// does not handle arrays (e.g. S7 BoxValueForWrite would crash).
|
||||
// Retired equipment-tag path (structurally empty in v3): array tags stay forced read-only
|
||||
// here. The live array-write capability (#610) is carried on RawTagPlan /
|
||||
// UnsReferenceVariable only — see RawNodeWritable.
|
||||
var writable = tag.Writable && !tag.IsArray;
|
||||
if (!SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, writable, AddressSpaceRealm.Uns, historianTagname, tag.IsArray, tag.ArrayLength)) failed++;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user