Merge #610: server-side array writes gated per driver capability (DriverArrayWriteSupport); ArrayWritable on the surgical whitelist + UNS Backing; shape/IndexRange refusal; ValueRank>1 out of scope
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe # Conflicts: # docs/drivers/S7.md
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++;
|
||||
}
|
||||
|
||||
@@ -166,8 +166,10 @@ public static class AddressSpaceChangeClassifier
|
||||
}).Equals(d.Current);
|
||||
|
||||
// v3 surgical raw-tag eligibility: a NON-alarm raw tag whose only differences are the in-place-updatable
|
||||
// attributes (Writable / IsHistorized / HistorianTagname / DataType / IsArray / ArrayLength) and/or its
|
||||
// ReferencingEquipmentPaths. The referencing set is node-inert for a VALUE tag — it only drives native-alarm
|
||||
// attributes (Writable / IsHistorized / HistorianTagname / DataType / IsArray / ArrayLength / ArrayWritable)
|
||||
// and/or its ReferencingEquipmentPaths. ArrayWritable (Gitea #610) only feeds the node's writability — the
|
||||
// same AccessLevel + OnWriteValue swap Writable already drives in place — so it must be listed here or
|
||||
// every array-tag edit that flips it would fall through to a full rebuild. The referencing set is node-inert for a VALUE tag — it only drives native-alarm
|
||||
// notifier wiring (the WP4 guard), which is why Alarm != null on EITHER side disqualifies. Identity fields
|
||||
// (NodeId is the diff key; TagId / DriverInstanceId / Name / ParentNodeId / Realm) still force a rebuild via
|
||||
// the with-equality default-closed pattern.
|
||||
@@ -181,6 +183,7 @@ public static class AddressSpaceChangeClassifier
|
||||
DataType = d.Current.DataType,
|
||||
IsArray = d.Current.IsArray,
|
||||
ArrayLength = d.Current.ArrayLength,
|
||||
ArrayWritable = d.Current.ArrayWritable,
|
||||
ReferencingEquipmentPaths = d.Current.ReferencingEquipmentPaths,
|
||||
}).Equals(d.Current);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -1928,7 +1928,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// synchronously so the SDK never applies it (no optimistic-Good-then-revert round-trip + no needless
|
||||
// device dispatch). Runs AFTER the authz gate (so we never leak structure detail to an unauthorised
|
||||
// caller) but BEFORE the optimistic dispatch below.
|
||||
var structure = EvaluateEquipmentWriteStructure(value, node);
|
||||
var structure = EvaluateEquipmentWriteRange(indexRange) ?? EvaluateEquipmentWriteStructure(value, node);
|
||||
if (structure is not null) return structure;
|
||||
|
||||
// Capture the optimistic value + the REAL prior value/status BEFORE the SDK applies the write
|
||||
@@ -2045,6 +2045,25 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
return new ServiceResult(StatusCodes.BadTypeMismatch, "null value write rejected");
|
||||
}
|
||||
|
||||
// Gitea #610 — an array node (writable only when its driver encodes whole-array writes) takes a whole
|
||||
// 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.
|
||||
if (node is BaseDataVariableState { ValueRank: ValueRanks.OneDimension } arrayNode)
|
||||
{
|
||||
if (value is not Array { Rank: 1 } array)
|
||||
{
|
||||
return new ServiceResult(StatusCodes.BadTypeMismatch, "a 1-D array node takes a 1-D array value");
|
||||
}
|
||||
var declared = arrayNode.ArrayDimensions is { Count: > 0 } dims ? dims[0] : 0u;
|
||||
if (declared > 0 && array.Length != declared)
|
||||
{
|
||||
return new ServiceResult(
|
||||
StatusCodes.BadTypeMismatch,
|
||||
$"array write of {array.Length} element(s) to a {declared}-element array node");
|
||||
}
|
||||
}
|
||||
|
||||
// Cheap, confidence-gated built-in-type compatibility check. Only when the node is a value variable
|
||||
// with a concretely-resolvable expected built-in type AND the payload's built-in type is also cheaply
|
||||
// resolvable do we compare; otherwise proceed and let the SDK's WriteValue coercion be authoritative.
|
||||
@@ -2072,6 +2091,20 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #610 — an equipment-tag write must be a WHOLE-value write. A partial (IndexRange) write to an
|
||||
/// array node is refused: every array-capable driver writes the whole block, so honouring a range would
|
||||
/// mean a read-modify-write of the device array the server does not hold authoritatively (the node value
|
||||
/// can be stale or optimistic), and the SDK would apply the range to the node before the device answered.
|
||||
/// A range on a scalar node is meaningless and refused the same way. Pure + static.
|
||||
/// </summary>
|
||||
/// <param name="indexRange">The write's parsed IndexRange (<see cref="NumericRange.Empty"/> when absent).</param>
|
||||
/// <returns><c>null</c> to proceed; otherwise <c>BadWriteNotSupported</c>.</returns>
|
||||
internal static ServiceResult? EvaluateEquipmentWriteRange(NumericRange indexRange) =>
|
||||
indexRange == NumericRange.Empty
|
||||
? null
|
||||
: 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;
|
||||
|
||||
Reference in New Issue
Block a user