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:
Joseph Doherty
2026-10-08 07:39:40 -04:00
20 changed files with 907 additions and 35 deletions
+3 -2
View File
@@ -80,8 +80,9 @@ removed later.
Editing a raw **tag's** attributes is no longer a fleet-wide event. A **non-alarm** Editing a raw **tag's** attributes is no longer a fleet-wide event. A **non-alarm**
raw-tag delta whose only differences are the in-place-updatable attributes — raw-tag delta whose only differences are the in-place-updatable attributes —
`Writable`, `IsHistorized`, `HistorianTagname`, `DataType`, `IsArray`, `ArrayLength` — `Writable`, `IsHistorized`, `HistorianTagname`, `DataType`, `IsArray`, `ArrayLength`,
and/or its set of referencing equipment is applied **in place** on the existing Raw-realm `ArrayWritable` (the driver's array-write capability, #610 — see
[Uns.md §Array writes](Uns.md#array-writes-610)) — and/or its set of referencing equipment is applied **in place** on the existing Raw-realm
node (`ISurgicalAddressSpaceSink.UpdateTagAttributes`), so every client subscription on node (`ISurgicalAddressSpaceSink.UpdateTagAttributes`), so every client subscription on
that tag and on the rest of the server survives. The same is true of **every** UNS that tag and on the rest of the server survives. The same is true of **every** UNS
tag-reference change: adding, removing or re-pointing a reference used to flip the tag-reference change: adding, removing or re-pointing a reference used to flip the
+42 -2
View File
@@ -312,10 +312,50 @@ Combined with historization (values are arrays — history of the whole array sn
| **AB Legacy** | PCCC multi-element file read via libplctag (cap 256 elements) | Unit-proven (sim fixture down) | | **AB Legacy** | PCCC multi-element file read via libplctag (cap 256 elements) | Unit-proven (sim fixture down) |
| **TwinCAT** | Raw-bytes read of the declared `SymbolPath` at the resolved array type's layout + per-element decode; native ADS notifications carry the whole array (same decode). Multi-dim PLC arrays publish flattened; a shorter `arrayLength` reads the leading sub-range | Live-verified 2026-10-07 on two TwinCAT 2.11 PLCs (see [TwinCAT.md §Array tags](drivers/TwinCAT.md#array-tags)) | | **TwinCAT** | Raw-bytes read of the declared `SymbolPath` at the resolved array type's layout + per-element decode; native ADS notifications carry the whole array (same decode). Multi-dim PLC arrays publish flattened; a shorter `arrayLength` reads the leading sub-range | Live-verified 2026-10-07 on two TwinCAT 2.11 PLCs (see [TwinCAT.md §Array tags](drivers/TwinCAT.md#array-tags)) |
### Array writes (#610)
An authored-`ReadWrite` array tag is **writable only when its driver can write that array** —
the server never advertises a write the driver will refuse. The capability is one table,
`DriverArrayWriteSupport` (`Core.Abstractions`), consulted by the composer at compose time and
carried on the plan records (`RawTagPlan.ArrayWritable`, mirrored onto every UNS reference as
`UnsReferenceVariable.BackingArrayWritable`); the node is writable iff
`Writable && (!IsArray || ArrayWritable)`, on the raw node and every UNS mirror alike.
| Driver | Array writes | Element types |
|---|---|---|
| **Modbus** | Yes | `region: Coils` (FC15, any element); `region: HoldingRegisters` with `Int16`/`UInt16`/`Bcd16`/`Int32`/`UInt32`/`Bcd32`/`Float32`/`Int64`/`UInt64`/`Float64` (FC16). Not BitInRegister / String / Bool-in-register; input regions are read-only |
| **S7** | Yes | `Byte`/`Int16`/`UInt16`/`Int32`/`UInt32`/`Float32` (one contiguous block). Not `Bool` (would clobber neighbouring bits) nor the wide / String / DateTime types |
| **OpcUaClient** | Yes | All — forwarded upstream unchanged; the upstream server decides |
| **Galaxy** | Yes | OPC UA `Boolean`/`Int32`/`Int64`/`Float`/`Double`/`String`/`DateTime` (the `MxValueEncoder` array variants) |
| AB CIP, AB Legacy, TwinCAT, FOCAS, MQTT, MTConnect, Sql, Calculation | **No** — scalar-only write encoders; array nodes stay read-only and a client write is `BadNotWritable` | — |
Each "Yes" row is pinned against the driver's real encoder by a parity test in that driver's
test project (`*ArrayWriteSupportParityTests`), so a driver gaining or losing an array encoder
fails a test until the table follows.
What a client sees on a writable array node:
- **Whole-array writes only.** The value must be a 1-D array of exactly `arrayLength` elements
(an unbounded `arrayLength`/`ArrayDimensions = [0]` takes any length); anything else is
`BadTypeMismatch`, refused inline before dispatch. A partial **IndexRange** write is
`BadWriteNotSupported` — every array-capable driver writes the whole block, and the server
does not hold the device array authoritatively enough to read-modify-write it.
- The same realm-qualified `WriteOperate` / ACL gate as a scalar applies to both NodeIds, and a
device-refused array write is reverted on the NodeId it was written through (the scalar
write-outcome self-correction, unchanged).
- Flipping a tag's array-write capability (a driver-type or element-type edit on an existing
array tag) is a **surgical** in-place writability update, not a rebuild: `ArrayWritable` is on
the classifier's raw-tag whitelist, and the mirrors repair through `BackingArrayWritable`.
### Out of scope (named deferrals) ### Out of scope (named deferrals)
- **Array writes** (inbound client→device write of an array value) — tagged for a follow-up phase. The S7 driver's array write path exists (`Byte`/`Int16`/`UInt16`/`Int32`/`UInt32`/`Float32`, not `Bool`; see [S7.md](drivers/S7.md)) but the applier still forces array nodes read-only (#610 item 1). - **Multi-dimensional arrays** (`ValueRank > 1`) — **out of scope by decision** (#610 item 3),
- **Multi-dimensional arrays** (`ValueRank > 1`) — not supported; all arrays are 1-D. not merely deferred. Every driver's array read decodes a flat 1-D block (TwinCAT already
publishes multi-dim PLC arrays flattened), and the node manager materialises every array
`ValueRank = OneDimension` with a single `ArrayDimensions` entry. Supporting rank > 1 would
need per-rank dimensions in TagConfig, the plan records, the classifier whitelist, the node
manager and every driver's decode/encode, and no consumer has asked for it. Author a
multi-dimensional PLC array as a flat 1-D tag (row-major), or as several 1-D tags.
- **Array historization** — a historized array tag materialises with the correct `Historizing` flag, but the historian backend treats the value as an opaque blob; per-element history is out of scope. (Continuous historization is numeric-analog only — array / non-numeric values are not recorded.) - **Array historization** — a historized array tag materialises with the correct `Historizing` flag, but the historian backend treats the value as an opaque blob; per-element history is out of scope. (Continuous historization is numeric-analog only — array / non-numeric values are not recorded.)
See the individual driver docs under `docs/drivers/` for per-driver implementation details. See the individual driver docs under `docs/drivers/` for per-driver implementation details.
+4 -3
View File
@@ -255,12 +255,13 @@ read is byte-granular, so a matching write would overwrite the 7 neighbouring bi
touches, and a safe bit-packed write needs read-modify-write. Mark Bool arrays `writable: false`. A `null` array element is rejected (`BadTypeMismatch`, nothing written) touches, and a safe bit-packed write needs read-modify-write. Mark Bool arrays `writable: false`. A `null` array element is rejected (`BadTypeMismatch`, nothing written)
rather than coerced to 0. An array write larger than one PDU is split by S7.Net into several writes and is rather than coerced to 0. An array write larger than one PDU is split by S7.Net into several writes and is
**not atomic** — a mid-write failure can leave the PLC array partly updated. **not atomic** — a mid-write failure can leave the PLC array partly updated.
Note the *server* still presents every array node read-only to OPC UA clients ([Uns.md §Array The server exposes these as **writable OPC UA array nodes** (#610) for exactly the element types above —
tags](../Uns.md#array-tags-1-d), #610 item 1) — the driver path is reachable today through the Client a `Bool` / wide-type S7 array stays read-only at the node (`BadNotWritable`), see [Uns.md §Array
writes](../Uns.md#array-writes-610). The driver path is also reachable directly through the Client
CLI / driver-level callers and is proven by the unit suite plus the snap7 fixture test CLI / driver-level callers and is proven by the unit suite plus the snap7 fixture test
(`S7_1500ArrayWriteTests`); the AdminUI tag editor rejects wide-type and writable-Bool arrays at author time. (`S7_1500ArrayWriteTests`); the AdminUI tag editor rejects wide-type and writable-Bool arrays at author time.
**Deferrals** — server-side exposure of array writes, multi-dimensional arrays, per-element historization, **Deferrals** — multi-dimensional arrays (out of scope by decision, see Uns.md), per-element historization,
wide-type array elements (`Int64`, `UInt64`, `Float64`, `String`, `DateTime`), and wide-type array elements (`Int64`, `UInt64`, `Float64`, `String`, `DateTime`), and
Timer/Counter array elements. Timer/Counter array elements.
@@ -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 &gt; 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;
}
@@ -229,9 +229,9 @@ public sealed class AddressSpaceApplier
} }
// Compute writable + historian EXACTLY as MaterialiseRawSubtree does so the in-place update // 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 // matches what a rebuild would have produced: an array is writable only when its driver can
// path can't handle them) and a blank historian override falls back to the RawPath (== NodeId). // write arrays (#610) and a blank historian override falls back to the RawPath (== NodeId).
var writable = d.Current.Writable && !d.Current.IsArray; var writable = RawNodeWritable(d.Current);
var historian = d.Current.IsHistorized var historian = d.Current.IsHistorized
? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.NodeId : d.Current.HistorianTagname) ? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.NodeId : d.Current.HistorianTagname)
: null; : null;
@@ -275,9 +275,9 @@ public sealed class AddressSpaceApplier
if (IsIdentityPreservingUnsRefDelta(d)) if (IsIdentityPreservingUnsRefDelta(d))
{ {
// Resolve writable + historian EXACTLY as MaterialiseUnsReferences does when it creates // 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 // the node: an array is writable only when its backing driver can write arrays (#610), and
// RawPath (which IS the backing raw tag's NodeId). // 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; var writable = UnsReferenceNodeWritable(d.Current);
var historian = d.Current.BackingIsHistorized var historian = d.Current.BackingIsHistorized
? (string.IsNullOrWhiteSpace(d.Current.BackingHistorianTagname) ? d.Current.BackingRawPath : d.Current.BackingHistorianTagname) ? (string.IsNullOrWhiteSpace(d.Current.BackingHistorianTagname) ? d.Current.BackingRawPath : d.Current.BackingHistorianTagname)
: null; : null;
@@ -467,6 +467,21 @@ public sealed class AddressSpaceApplier
/// </summary> /// </summary>
/// <param name="d">The UNS reference delta to classify.</param> /// <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> /// <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) => private static bool IsIdentityPreservingUnsRefDelta(AddressSpacePlan.UnsReferenceDelta d) =>
string.Equals(d.Previous.NodeId, d.Current.NodeId, StringComparison.Ordinal) && string.Equals(d.Previous.NodeId, d.Current.NodeId, StringComparison.Ordinal) &&
string.Equals(d.Previous.EquipmentId, d.Current.EquipmentId, StringComparison.Ordinal) && string.Equals(d.Previous.EquipmentId, d.Current.EquipmentId, StringComparison.Ordinal) &&
@@ -972,11 +987,12 @@ public sealed class AddressSpaceApplier
else else
{ {
// A historized tag materialises Historizing + HistoryRead; the effective historian tagname is // 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? historianTagname = t.IsHistorized
? (string.IsNullOrWhiteSpace(t.HistorianTagname) ? t.NodeId : t.HistorianTagname) ? (string.IsNullOrWhiteSpace(t.HistorianTagname) ? t.NodeId : t.HistorianTagname)
: null; : 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++; 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? historianTagname = v.BackingIsHistorized
? (string.IsNullOrWhiteSpace(v.BackingHistorianTagname) ? v.BackingRawPath : v.BackingHistorianTagname) ? (string.IsNullOrWhiteSpace(v.BackingHistorianTagname) ? v.BackingRawPath : v.BackingHistorianTagname)
: null; : 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)) if (!SafeEnsureVariable(v.NodeId, v.EquipmentId, v.EffectiveName, v.DataType, writable, AddressSpaceRealm.Uns, historianTagname, isArray, arrayLength))
{ {
failed++; failed++;
@@ -1120,9 +1136,9 @@ public sealed class AddressSpaceApplier
string? historianTagname = tag.IsHistorized string? historianTagname = tag.IsHistorized
? (string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname) ? (string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname)
: null; : null;
// Array writes are out of scope (Phase 4c read-only surface): force array tags read-only // Retired equipment-tag path (structurally empty in v3): array tags stay forced read-only
// even if authored ReadWrite, so a client write cannot reach the driver write path which // here. The live array-write capability (#610) is carried on RawTagPlan /
// does not handle arrays (e.g. S7 BoxValueForWrite would crash). // UnsReferenceVariable only — see RawNodeWritable.
var writable = tag.Writable && !tag.IsArray; var writable = tag.Writable && !tag.IsArray;
if (!SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, writable, AddressSpaceRealm.Uns, historianTagname, tag.IsArray, tag.ArrayLength)) failed++; 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); }).Equals(d.Current);
// v3 surgical raw-tag eligibility: a NON-alarm raw tag whose only differences are the in-place-updatable // 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 // attributes (Writable / IsHistorized / HistorianTagname / DataType / IsArray / ArrayLength / ArrayWritable)
// ReferencingEquipmentPaths. The referencing set is node-inert for a VALUE tag — it only drives native-alarm // 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 // 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 // (NodeId is the diff key; TagId / DriverInstanceId / Name / ParentNodeId / Realm) still force a rebuild via
// the with-equality default-closed pattern. // the with-equality default-closed pattern.
@@ -181,6 +183,7 @@ public static class AddressSpaceChangeClassifier
DataType = d.Current.DataType, DataType = d.Current.DataType,
IsArray = d.Current.IsArray, IsArray = d.Current.IsArray,
ArrayLength = d.Current.ArrayLength, ArrayLength = d.Current.ArrayLength,
ArrayWritable = d.Current.ArrayWritable,
ReferencingEquipmentPaths = d.Current.ReferencingEquipmentPaths, ReferencingEquipmentPaths = d.Current.ReferencingEquipmentPaths,
}).Equals(d.Current); }).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.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; 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"/>. /// 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 /// A null <see cref="HistorianTagname"/> means the historian tagname defaults to the RawPath (resolved
/// later, not here). /// 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 &amp;&amp; (!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 /// <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 /// 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 /// <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, string? HistorianTagname = null,
bool IsArray = false, bool IsArray = false,
uint? ArrayLength = null, uint? ArrayLength = null,
AddressSpaceRealm Realm = AddressSpaceRealm.Raw) AddressSpaceRealm Realm = AddressSpaceRealm.Raw,
bool ArrayWritable = false)
{ {
/// <inheritdoc /> /// <inheritdoc />
// Structural equality: the auto-generated record equality compares ReferencingEquipmentPaths (an // Structural equality: the auto-generated record equality compares ReferencingEquipmentPaths (an
@@ -185,6 +192,7 @@ public sealed record RawTagPlan(
IsArray == other.IsArray && IsArray == other.IsArray &&
ArrayLength == other.ArrayLength && ArrayLength == other.ArrayLength &&
Realm == other.Realm && Realm == other.Realm &&
ArrayWritable == other.ArrayWritable &&
ReferencingEquipmentPaths.SequenceEqual(other.ReferencingEquipmentPaths, StringComparer.Ordinal); ReferencingEquipmentPaths.SequenceEqual(other.ReferencingEquipmentPaths, StringComparer.Ordinal);
/// <inheritdoc /> /// <inheritdoc />
@@ -194,7 +202,7 @@ public sealed record RawTagPlan(
hash.Add(TagId); hash.Add(NodeId); hash.Add(ParentNodeId); hash.Add(DriverInstanceId); 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(Name); hash.Add(DataType); hash.Add(Writable); hash.Add(Alarm);
hash.Add(IsHistorized); hash.Add(HistorianTagname); hash.Add(IsArray); hash.Add(ArrayLength); 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); foreach (var p in ReferencingEquipmentPaths) hash.Add(p, StringComparer.Ordinal);
return hash.ToHashCode(); return hash.ToHashCode();
} }
@@ -226,7 +234,9 @@ public sealed record RawTagPlan(
/// existing machinery repairs it: the applier removes the previous UNS node and /// existing machinery repairs it: the applier removes the previous UNS node and
/// <see cref="AddressSpaceApplier.MaterialiseUnsReferences"/> recreates it from these values. /// <see cref="AddressSpaceApplier.MaterialiseUnsReferences"/> recreates it from these values.
/// <see cref="DataType"/> / <see cref="Writable"/> were already inherited onto the record and are /// <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 /// <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 /// 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 /// 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, string? BackingHistorianTagname = null,
bool BackingIsArray = false, bool BackingIsArray = false,
uint? BackingArrayLength = null, 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 UnsAreaProjection(string UnsAreaId, string DisplayName);
public sealed record UnsLineProjection(string UnsLineId, 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, rawFolders, driverInstances, devices, tagGroups, tags, unsTagReferences,
unsAreas, unsLines, equipment, topology); unsAreas, unsLines, equipment, topology);
var unsReferenceVariables = BuildUnsReferenceVariables( var unsReferenceVariables = BuildUnsReferenceVariables(
unsTagReferences, tags, unsAreas, unsLines, equipment, topology); unsTagReferences, tags, unsAreas, unsLines, equipment, topology, driverInstances);
return new AddressSpaceComposition(areas, lines, nodes, plans, alarms) 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 // TagId → the set of referencing equipment UNS folder paths (Area/Line/Equipment), for the
// native-alarm multi-notifier. Distinct + ordinal-sorted for determinism. // native-alarm multi-notifier. Distinct + ordinal-sorted for determinism.
var equipPathByTag = BuildReferencingEquipmentPathsByTag(unsTagReferences, unsAreas, unsLines, equipment); var equipPathByTag = BuildReferencingEquipmentPathsByTag(unsTagReferences, unsAreas, unsLines, equipment);
var driverTypeById = BuildDriverTypeById(driverInstances);
var rawTags = new List<RawTagPlan>(); var rawTags = new List<RawTagPlan>();
foreach (var t in tags ?? Array.Empty<Tag>()) foreach (var t in tags ?? Array.Empty<Tag>())
@@ -767,6 +779,8 @@ public static class AddressSpaceComposer
: topology.DevicePaths.GetValueOrDefault(t.DeviceId); : topology.DevicePaths.GetValueOrDefault(t.DeviceId);
var driverInstanceId = topology.DriverIdByDevice.GetValueOrDefault(t.DeviceId, string.Empty); var driverInstanceId = topology.DriverIdByDevice.GetValueOrDefault(t.DeviceId, string.Empty);
var intent = TagConfigIntent.Parse(t.TagConfig); 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 var alarm = intent.Alarm is { } a
? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva) ? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva)
: null; : null;
@@ -785,7 +799,8 @@ public static class AddressSpaceComposer
IsHistorized: intent.IsHistorized, IsHistorized: intent.IsHistorized,
HistorianTagname: intent.HistorianTagname, HistorianTagname: intent.HistorianTagname,
IsArray: intent.IsArray, IsArray: intent.IsArray,
ArrayLength: intent.ArrayLength)); ArrayLength: intent.ArrayLength,
ArrayWritable: arrayWritable));
} }
containers.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId)); containers.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
@@ -793,6 +808,13 @@ public static class AddressSpaceComposer
return (containers, rawTags); 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> /// <summary>
/// Emit the UNS reference Variables: one per <see cref="UnsTagReference"/> whose backing raw tag + /// 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 /// 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<UnsArea> unsAreas,
IReadOnlyList<UnsLine> unsLines, IReadOnlyList<UnsLine> unsLines,
IReadOnlyList<Equipment> equipment, IReadOnlyList<Equipment> equipment,
RawTopology topology) RawTopology topology,
IReadOnlyList<DriverInstance> driverInstances)
{ {
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>(); var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
if (refs.Count == 0) return Array.Empty<UnsReferenceVariable>(); if (refs.Count == 0) return Array.Empty<UnsReferenceVariable>();
var driverTypeById = BuildDriverTypeById(driverInstances);
var tagsById = (tags ?? Array.Empty<Tag>()) var tagsById = (tags ?? Array.Empty<Tag>())
.GroupBy(t => t.TagId, StringComparer.Ordinal) .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 // 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. // tag plan uses, so the two sides can never disagree.
var intent = TagConfigIntent.Parse(tag.TagConfig); 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( vars.Add(new UnsReferenceVariable(
UnsTagReferenceId: r.UnsTagReferenceId, UnsTagReferenceId: r.UnsTagReferenceId,
@@ -852,7 +881,8 @@ public static class AddressSpaceComposer
BackingIsHistorized: intent.IsHistorized, BackingIsHistorized: intent.IsHistorized,
BackingHistorianTagname: intent.HistorianTagname, BackingHistorianTagname: intent.HistorianTagname,
BackingIsArray: intent.IsArray, BackingIsArray: intent.IsArray,
BackingArrayLength: intent.ArrayLength)); BackingArrayLength: intent.ArrayLength,
BackingArrayWritable: backingArrayWritable));
} }
vars.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId)); 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 // 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 // device dispatch). Runs AFTER the authz gate (so we never leak structure detail to an unauthorised
// caller) but BEFORE the optimistic dispatch below. // 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; if (structure is not null) return structure;
// Capture the optimistic value + the REAL prior value/status BEFORE the SDK applies the write // 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"); 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 // 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 // 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. // 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; 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> /// <summary>
/// Conservative built-in-type compatibility test for <see cref="EvaluateEquipmentWriteStructure"/>. /// Conservative built-in-type compatibility test for <see cref="EvaluateEquipmentWriteStructure"/>.
/// Returns true (compatible — allow through) unless there is a confident mismatch. Exact matches pass; /// Returns true (compatible — allow through) unless there is a confident mismatch. Exact matches pass;
@@ -0,0 +1,87 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
/// <summary>
/// Gitea #610 — the per-driver array-write capability table. The table's agreement with each listed driver's
/// real encoder is pinned in that driver's own test project (<c>*ArrayWriteSupportParityTests</c>); these
/// tests pin the table's own reading of TagConfig (defaults, numeric enums, malformed blobs).
/// </summary>
public sealed class DriverArrayWriteSupportTests
{
[Theory]
[InlineData("{\"region\":\"Coils\",\"address\":0,\"dataType\":\"Bool\"}", true)]
[InlineData("{\"region\":\"Coils\",\"address\":0}", true)] // FC15 ignores dataType
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Float32\"}", true)]
[InlineData("{\"region\":\"holdingregisters\",\"address\":0,\"dataType\":\"bcd16\"}", true)] // case-insensitive, like the driver
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0}", true)] // dataType ⇒ Int16
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"BitInRegister\"}", false)]
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"String\"}", false)]
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Bool\"}", false)]
[InlineData("{\"region\":\"InputRegisters\",\"address\":0,\"dataType\":\"Int16\"}", false)] // read-only region
[InlineData("{\"region\":\"DiscreteInputs\",\"address\":0}", false)]
[InlineData("{\"region\":0,\"address\":0,\"dataType\":\"Int16\"}", true)] // numeric ⇒ absent ⇒ HoldingRegisters (driver reads it that way)
[InlineData("not json", false)]
[InlineData("", false)]
public void Modbus_rows_follow_region_and_element_type(string tagConfig, bool expected) =>
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tagConfig).ShouldBe(expected);
[Theory]
[InlineData("Byte", true)]
[InlineData("Int16", true)]
[InlineData("UInt16", true)]
[InlineData("Int32", true)]
[InlineData("UInt32", true)]
[InlineData("Float32", true)]
[InlineData("Bool", false)]
[InlineData("Int64", false)]
[InlineData("UInt64", false)]
[InlineData("Float64", false)]
[InlineData("String", false)]
[InlineData("DateTime", false)]
public void S7_rows_follow_the_element_type(string s7Type, bool expected) =>
DriverArrayWriteSupport.Supports(DriverTypeNames.S7, "Int16", $"{{\"address\":\"DB1.DBW0\",\"dataType\":\"{s7Type}\"}}")
.ShouldBe(expected);
[Fact]
public void S7_without_a_dataType_key_is_not_an_S7_tag() =>
DriverArrayWriteSupport.Supports(DriverTypeNames.S7, "Int16", "{\"address\":\"DB1.DBW0\"}").ShouldBeFalse();
[Theory]
[InlineData("Boolean", true)]
[InlineData("Int32", true)]
[InlineData("Int64", true)]
[InlineData("Float", true)]
[InlineData("Double", true)]
[InlineData("String", true)]
[InlineData("DateTime", true)]
[InlineData("Int16", false)]
[InlineData("UInt32", false)]
[InlineData("Byte", false)]
public void Galaxy_rows_follow_the_opc_ua_data_type(string dataType, bool expected) =>
DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, dataType, "{\"FullName\":\"Obj.Attr\"}").ShouldBe(expected);
[Fact]
public void OpcUaClient_forwards_every_array() =>
DriverArrayWriteSupport.Supports(DriverTypeNames.OpcUaClient, "UInt16", "{\"nodeId\":\"ns=2;s=x\"}").ShouldBeTrue();
/// <summary>Every driver type NOT in the table — and an unknown or absent one — stays read-only.</summary>
[Fact]
public void Every_other_driver_type_is_not_array_writable()
{
var listed = new[] { DriverTypeNames.Modbus, DriverTypeNames.S7, DriverTypeNames.OpcUaClient, DriverTypeNames.Galaxy };
var others = DriverTypeNames.All.Except(listed).ToList();
others.ShouldNotBeEmpty();
const string blob = "{\"region\":\"Coils\",\"dataType\":\"Int32\"}";
// Positive control: the same blob IS array-writable under the listed drivers that read it.
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int32", blob).ShouldBeTrue();
DriverArrayWriteSupport.Supports(DriverTypeNames.S7, "Int32", blob).ShouldBeTrue();
foreach (var driverType in others.Append("NoSuchDriver").Append(null))
{
// A blob every listed driver would accept, so only the driver type can make the answer false.
DriverArrayWriteSupport.Supports(driverType, "Int32", "{\"region\":\"Coils\",\"dataType\":\"Int32\"}")
.ShouldBeFalse(driverType ?? "<null>");
}
}
}
@@ -0,0 +1,63 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary>
/// Gitea #610 — pins <see cref="DriverArrayWriteSupport"/>'s Galaxy row against the REAL write encoder
/// (<see cref="MxValueEncoder"/>). The OPC UA server hands the driver the CLR array type of the node's
/// <c>DataType</c> (the SDK refuses a mistyped array before the write handler runs), so for every
/// <c>Tag.DataType</c> the server can materialise, the node is advertised writable exactly when the encoder
/// accepts that array type.
/// </summary>
[Trait("Category", "Unit")]
public sealed class GalaxyArrayWriteSupportParityTests
{
// Tag.DataType name → the CLR array the OPC UA stack delivers for a 1-D write of that type.
private static readonly Dictionary<string, Array> ClrArrays = new()
{
["Boolean"] = new[] { true, false },
["SByte"] = new sbyte[] { 1, 2 },
["Byte"] = new byte[] { 1, 2 },
["Int16"] = new short[] { 1, 2 },
["UInt16"] = new ushort[] { 1, 2 },
["Int32"] = new[] { 1, 2 },
["UInt32"] = new uint[] { 1, 2 },
["Int64"] = new long[] { 1, 2 },
["UInt64"] = new ulong[] { 1, 2 },
["Float"] = new[] { 1f, 2f },
["Double"] = new[] { 1d, 2d },
["String"] = new[] { "a", "b" },
["DateTime"] = new[] { DateTime.UtcNow, DateTime.UtcNow },
};
public static TheoryData<string> DataTypes() => new(ClrArrays.Keys);
[Theory]
[MemberData(nameof(DataTypes))]
public void Table_agrees_with_MxValueEncoder(string dataType)
{
bool encoderAccepts;
try
{
MxValueEncoder.Encode(ClrArrays[dataType]);
encoderAccepts = true;
}
catch (ArgumentException)
{
encoderAccepts = false;
}
// UInt32 / UInt64 are a deliberate exclusion: CLR array covariance lets uint[] / ulong[] match the encoder's
// int[] / long[] arms, so it "accepts" them by REINTERPRETING the bits (uint.MaxValue → -1) — unlike the
// scalar arms, which range-check. A Galaxy attribute is never unsigned (DataTypeMap), so the table refuses
// rather than advertise a write that silently corrupts large values.
var expected = encoderAccepts && dataType is not ("UInt32" or "UInt64");
if (dataType is "UInt32" or "UInt64") encoderAccepts.ShouldBeTrue("the covariance hazard this exclusion documents");
DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, dataType, "{\"FullName\":\"Obj.Attr\"}")
.ShouldBe(expected, dataType);
}
}
@@ -0,0 +1,58 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Gitea #610 — pins <see cref="DriverArrayWriteSupport"/>'s Modbus row against the REAL write path. For every
/// region × element type, the server advertises an array node writable exactly when the driver accepts a
/// whole-array write for it. A driver change that gains or loses an array encoder fails here until the table
/// follows — so the server can never advertise a write this driver refuses (nor hide one it accepts).
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusArrayWriteSupportParityTests
{
private static IEnumerable<(ModbusRegion Region, ModbusDataType DataType)> Shapes() =>
from region in Enum.GetValues<ModbusRegion>()
from dt in Enum.GetValues<ModbusDataType>()
select (region, dt);
public static TheoryData<ModbusRegion, ModbusDataType> AllShapes()
{
var data = new TheoryData<ModbusRegion, ModbusDataType>();
foreach (var (region, dt) in Shapes()) data.Add(region, dt);
return data;
}
[Theory]
[MemberData(nameof(AllShapes))]
public async Task Table_agrees_with_the_driver_write_path(ModbusRegion region, ModbusDataType dataType)
{
var json = $$"""{"region":"{{region}}","address":10,"dataType":"{{dataType}}","stringLength":4,"isArray":true,"arrayLength":2}""";
var fake = new ModbusDriverTests.FakeTransport();
var drv = new ModbusDriver(
new ModbusDriverOptions { Host = "fake", RawTags = [new RawTagEntry("cell/modbus/arr", json, false)] },
"modbus-array-parity", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
// A two-element value every element type can convert (Convert.ToBoolean / ToInt16 / ToSingle … of 1).
var result = await drv.WriteAsync([new WriteRequest("cell/modbus/arr", new object[] { 1, 1 })], CancellationToken.None);
var driverAccepts = result.Single().StatusCode == 0u;
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", json).ShouldBe(
driverAccepts, $"region={region} dataType={dataType}: driver status 0x{result.Single().StatusCode:X8}");
}
/// <summary>Positive control: the theory above is not vacuously "everything refused".</summary>
[Fact]
public void The_matrix_contains_both_answers()
{
var answers = Shapes()
.Select(s => DriverArrayWriteSupport.Supports(
DriverTypeNames.Modbus, "Int16",
$$"""{"region":"{{s.Region}}","address":10,"dataType":"{{s.DataType}}"}"""))
.ToHashSet();
answers.ShouldBe(new[] { true, false }, ignoreOrder: true);
}
}
@@ -0,0 +1,49 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Gitea #610 — pins <see cref="DriverArrayWriteSupport"/>'s S7 row against the REAL array encoder
/// (<see cref="S7Driver.EncodeArrayBlock"/>). For every <see cref="S7DataType"/>, the server advertises an
/// array node writable exactly when the encoder accepts the element type, so a driver change that adds or
/// drops an array element type fails here until the table follows.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7ArrayWriteSupportParityTests
{
public static TheoryData<S7DataType> AllTypes() => new(Enum.GetValues<S7DataType>());
// An address whose access size matches the element type, so only the element type can make the encoder refuse.
private static string AddressFor(S7DataType dt) => dt switch
{
S7DataType.Bool => "DB1.DBX0.0",
S7DataType.Byte => "DB1.DBB0",
S7DataType.Int16 or S7DataType.UInt16 => "DB1.DBW0",
_ => "DB1.DBD0",
};
[Theory]
[MemberData(nameof(AllTypes))]
public void Table_agrees_with_EncodeArrayBlock(S7DataType dataType)
{
var address = AddressFor(dataType);
var tag = new S7TagDefinition("Arr", address, dataType, Writable: true, ArrayCount: 2);
bool encoderAccepts;
try
{
S7Driver.EncodeArrayBlock(tag, S7AddressParser.Parse(address), new object[] { 1, 1 });
encoderAccepts = true;
}
catch (NotSupportedException)
{
encoderAccepts = false;
}
DriverArrayWriteSupport.Supports(
DriverTypeNames.S7, "Int16", $$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":2}""")
.ShouldBe(encoderAccepts, dataType.ToString());
}
}
@@ -0,0 +1,225 @@
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// Gitea #610 — server-side array writes, over the wire. The address space is built by the PRODUCTION
/// composer + applier (not hand-made <c>EnsureVariable</c> calls), so these tests prove the whole chain:
/// <c>DriverArrayWriteSupport</c> → <c>RawTagPlan.ArrayWritable</c> / <c>UnsReferenceVariable.BackingArrayWritable</c>
/// → the node's AccessLevel → a real client write through the realm-qualified <c>WriteOperate</c> gate →
/// the node write gateway (the stand-in for <c>DriverHostActor</c>'s routing).
/// <list type="bullet">
/// <item>An S7 Int16 array (S7 encodes non-Bool arrays as one block) is writable on BOTH its raw node and
/// its UNS mirror, and a whole-array write round-trips.</item>
/// <item>An AbCip DInt array (AbCip has a scalar-only write encoder) stays read-only even though it is
/// authored <c>ReadWrite</c>: the write is <c>BadNotWritable</c> at the node and never reaches the
/// gateway — the "never advertise a write the driver will refuse" rule.</item>
/// <item>A device-refused array write is reverted on whichever NodeId it was written through.</item>
/// <item>A wrong-length array and an IndexRange (partial) write are refused inline, before dispatch, and
/// leave the node holding its prior array.</item>
/// </list>
/// </summary>
[Trait("Category", "Integration")]
public sealed class ArrayWriteWireTests
{
private const string S7ArrayRaw = "Plant/S7a/plc1/Levels";
private const string AbArrayRaw = "Plant/Ab1/plc2/Counts";
private const string S7ArrayUns = "filling/line-1/station-1/Levels";
private sealed class RecordingWriteGateway(bool succeed) : IOpcUaNodeWriteGateway
{
public List<(string NodeId, object? Value, AddressSpaceRealm Realm)> Writes { get; } = [];
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct)
{
lock (Writes) Writes.Add((nodeId, value, realm));
return Task.FromResult(new NodeWriteOutcome(succeed, succeed ? null : "device refused"));
}
public int Count { get { lock (Writes) return Writes.Count; } }
}
/// <summary>Composes + materialises the two array tags and the UNS mirror of the S7 one, through the
/// production composer and applier, then publishes an initial value to every node.</summary>
private static async Task<ServerEventRig> StartAsync(string name, CancellationToken ct)
{
var rig = await ServerEventRig.StartAsync(name, encrypted: false, ct);
var folder = new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = "Plant" };
var s7 = new DriverInstance
{
DriverInstanceId = "drv-s7", ClusterId = "c1", RawFolderId = "raw-plant",
Name = "S7a", DriverType = "S7", DriverConfig = "{}",
};
var ab = new DriverInstance
{
DriverInstanceId = "drv-ab", ClusterId = "c1", RawFolderId = "raw-plant",
Name = "Ab1", DriverType = "AbCip", DriverConfig = "{}",
};
var plc1 = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-s7", Name = "plc1", DeviceConfig = "{}" };
var plc2 = new Device { DeviceId = "dev-2", DriverInstanceId = "drv-ab", Name = "plc2", DeviceConfig = "{}" };
var levels = new Tag
{
TagId = "t-levels", DeviceId = "dev-1", Name = "Levels", DataType = "Int16",
AccessLevel = TagAccessLevel.ReadWrite,
TagConfig = "{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":4}",
};
var counts = new Tag
{
TagId = "t-counts", DeviceId = "dev-2", Name = "Counts", DataType = "Int32",
AccessLevel = TagAccessLevel.ReadWrite,
TagConfig = "{\"tagPath\":\"Counts\",\"dataType\":\"DInt\",\"isArray\":true,\"arrayLength\":4}",
};
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
var equip = new Equipment { EquipmentId = "eq-1", UnsLineId = "line-1", Name = "station-1", MachineCode = "ST1" };
var reference = new UnsTagReference { UnsTagReferenceId = "ref-levels", EquipmentId = "eq-1", TagId = "t-levels" };
var composition = AddressSpaceComposer.Compose(
[area], [line], [equip], [s7, ab], Array.Empty<ScriptedAlarm>(),
unsTagReferences: [reference], tags: [levels, counts],
rawFolders: [folder], devices: [plc1, plc2]);
var applier = new AddressSpaceApplier(rig.Sink, NullLogger<AddressSpaceApplier>.Instance);
applier.MaterialiseHierarchy(composition).ShouldBe(0);
applier.MaterialiseRawSubtree(composition).ShouldBe(0);
applier.MaterialiseUnsReferences(composition).ShouldBe(0);
var ts = DateTime.UtcNow;
rig.Sink.WriteValue(S7ArrayRaw, new short[] { 1, 2, 3, 4 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
rig.Sink.WriteValue(S7ArrayUns, new short[] { 1, 2, 3, 4 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns);
rig.Sink.WriteValue(AbArrayRaw, new[] { 5, 6, 7, 8 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
return rig;
}
private static NodeId Raw(ISession s, string id) =>
new(id, (ushort)s.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
private static async Task<StatusCode> WriteAsync(ISession session, NodeId node, object value, CancellationToken ct, string? indexRange = null)
{
var response = await session.WriteAsync(null, new WriteValueCollection
{
new WriteValue { NodeId = node, AttributeId = Attributes.Value, IndexRange = indexRange, Value = new DataValue(new Variant(value)) },
}, ct);
return response.Results.ShouldHaveSingleItem();
}
private static async Task<short[]> ReadShortsAsync(ISession session, NodeId node, CancellationToken ct) =>
(short[])(await session.ReadValueAsync(node, ct)).Value;
private static async Task<byte> ReadAccessLevelAsync(ISession session, NodeId node, CancellationToken ct)
{
var response = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, new ReadValueIdCollection
{
new ReadValueId { NodeId = node, AttributeId = Attributes.UserAccessLevel },
}, ct);
return (byte)response.Results.ShouldHaveSingleItem().Value;
}
private static async Task WaitForAsync(Func<Task<bool>> condition)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
while (DateTime.UtcNow < deadline)
{
if (await condition()) return;
await Task.Delay(50);
}
(await condition()).ShouldBeTrue("condition not met within timeout");
}
/// <summary>A writable-capable array round-trips a whole-array write through BOTH NodeIds.</summary>
[Fact]
public async Task A_writable_array_roundtrips_a_whole_array_write_through_both_nodeids()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await StartAsync("array-write", ct);
var gateway = new RecordingWriteGateway(succeed: true);
rig.Nm.NodeWriteGateway = gateway;
using var session = await rig.ConnectAsync(anonymous: false, ct);
var raw = Raw(session, S7ArrayRaw);
var uns = ServerEventRig.UnsNodeId(session, S7ArrayUns);
(await ReadAccessLevelAsync(session, raw, ct) & AccessLevels.CurrentWrite).ShouldBe(AccessLevels.CurrentWrite);
(await ReadAccessLevelAsync(session, uns, ct) & AccessLevels.CurrentWrite).ShouldBe(AccessLevels.CurrentWrite);
(await WriteAsync(session, raw, new short[] { 10, 20, 30, 40 }, ct)).ShouldBe((StatusCode)StatusCodes.Good);
(await ReadShortsAsync(session, raw, ct)).ShouldBe(new short[] { 10, 20, 30, 40 });
(await WriteAsync(session, uns, new short[] { -1, -2, -3, -4 }, ct)).ShouldBe((StatusCode)StatusCodes.Good);
(await ReadShortsAsync(session, uns, ct)).ShouldBe(new short[] { -1, -2, -3, -4 });
lock (gateway.Writes)
{
gateway.Writes.Count.ShouldBe(2);
gateway.Writes[0].Realm.ShouldBe(AddressSpaceRealm.Raw);
gateway.Writes[0].Value.ShouldBe(new short[] { 10, 20, 30, 40 });
gateway.Writes[1].Realm.ShouldBe(AddressSpaceRealm.Uns);
gateway.Writes[1].Value.ShouldBe(new short[] { -1, -2, -3, -4 });
}
}
/// <summary>An array on a driver with no array encoder stays read-only though authored ReadWrite.</summary>
[Fact]
public async Task An_array_the_driver_cannot_write_is_read_only_and_a_write_is_BadNotWritable()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await StartAsync("array-ro", ct);
var gateway = new RecordingWriteGateway(succeed: true);
rig.Nm.NodeWriteGateway = gateway;
using var session = await rig.ConnectAsync(anonymous: false, ct);
var ab = Raw(session, AbArrayRaw);
(await ReadAccessLevelAsync(session, ab, ct) & AccessLevels.CurrentWrite).ShouldBe((byte)0);
(await WriteAsync(session, ab, new[] { 50, 60, 70, 80 }, ct)).ShouldBe((StatusCode)StatusCodes.BadNotWritable);
((int[])(await session.ReadValueAsync(ab, ct)).Value).ShouldBe(new[] { 5, 6, 7, 8 });
gateway.Count.ShouldBe(0);
// Positive control: the same session's write to the S7 array is dispatched, so the refusal is the node's.
(await WriteAsync(session, Raw(session, S7ArrayRaw), new short[] { 9, 9, 9, 9 }, ct)).ShouldBe((StatusCode)StatusCodes.Good);
gateway.Count.ShouldBe(1);
}
/// <summary>A device-refused array write reverts the node it was written through, on either NodeId.</summary>
[Fact]
public async Task A_device_refused_array_write_is_reverted_through_either_nodeid()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await StartAsync("array-revert", ct);
rig.Nm.NodeWriteGateway = new RecordingWriteGateway(succeed: false);
using var session = await rig.ConnectAsync(anonymous: false, ct);
foreach (var node in new[] { Raw(session, S7ArrayRaw), ServerEventRig.UnsNodeId(session, S7ArrayUns) })
{
(await WriteAsync(session, node, new short[] { 99, 99, 99, 99 }, ct)).ShouldBe((StatusCode)StatusCodes.Good);
await WaitForAsync(async () => (await ReadShortsAsync(session, node, ct)).SequenceEqual(new short[] { 1, 2, 3, 4 }));
}
}
/// <summary>A wrong-length array and a partial (IndexRange) write are refused before dispatch.</summary>
[Fact]
public async Task A_wrong_length_or_index_range_array_write_is_refused_inline_and_leaves_the_node_intact()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await StartAsync("array-shape", ct);
var gateway = new RecordingWriteGateway(succeed: true);
rig.Nm.NodeWriteGateway = gateway;
using var session = await rig.ConnectAsync(anonymous: false, ct);
var raw = Raw(session, S7ArrayRaw);
StatusCode.IsBad(await WriteAsync(session, raw, new short[] { 7, 7, 7 }, ct)).ShouldBeTrue("a 3-element write to a 4-element array");
(await WriteAsync(session, raw, new short[] { 7, 7, 7, 7 }, ct, indexRange: "0:3")).ShouldBe((StatusCode)StatusCodes.BadWriteNotSupported, "an IndexRange write, even one spanning the whole array");
(await ReadShortsAsync(session, raw, ct)).ShouldBe(new short[] { 1, 2, 3, 4 });
gateway.Count.ShouldBe(0);
// Positive control: a correctly-shaped whole-array write on the same node is dispatched.
(await WriteAsync(session, raw, new short[] { 7, 7, 7, 7 }, ct)).ShouldBe((StatusCode)StatusCodes.Good);
gateway.Count.ShouldBe(1);
}
}
@@ -316,6 +316,41 @@ public sealed class AddressSpaceApplierRawUnsTests
v.Writable.ShouldBeFalse(); // Writable && !IsArray ⇒ an array mirror is read-only v.Writable.ShouldBeFalse(); // Writable && !IsArray ⇒ an array mirror is read-only
} }
/// <summary>Gitea #610: an authored-ReadWrite ARRAY materialises writable on the raw node AND its UNS mirror
/// exactly when the driver can write arrays (<c>ArrayWritable</c> / <c>BackingArrayWritable</c>), and
/// read-only otherwise; an authored-Read array stays read-only either way.</summary>
[Theory]
[InlineData(true, true, true)]
[InlineData(true, false, false)]
[InlineData(false, true, false)]
public void Array_nodes_are_writable_only_when_the_driver_can_write_arrays(bool authoredWritable, bool arrayWritable, bool expected)
{
var sink = new RealmRecordingSink();
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
RawTags = new[]
{
new RawTagPlan("t1", "Plant/A/dev/levels", "Plant/A/dev", "drv", "levels", "Int16",
Writable: authoredWritable, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(),
IsArray: true, ArrayLength: 4, ArrayWritable: arrayWritable),
},
UnsReferenceVariables = new[]
{
new UnsReferenceVariable("r1", "EQ-1", "filling/line1/station1/levels", "levels",
"Plant/A/dev/levels", "Int16", Writable: authoredWritable,
BackingIsArray: true, BackingArrayLength: 4, BackingArrayWritable: arrayWritable),
},
};
var applier = NewApplier(sink);
applier.MaterialiseRawSubtree(composition);
applier.MaterialiseUnsReferences(composition);
sink.Variables.Count.ShouldBe(2);
sink.Variables.ShouldAllBe(v => v.Writable == expected && v.IsArray);
}
/// <summary>The historian-resolution semantics carried over verbatim from the old back-lookup: a BLANK /// <summary>The historian-resolution semantics carried over verbatim from the old back-lookup: a BLANK
/// <c>BackingHistorianTagname</c> on a historized reference falls back to the <c>BackingRawPath</c> (which /// <c>BackingHistorianTagname</c> on a historized reference falls back to the <c>BackingRawPath</c> (which
/// IS the backing raw tag's NodeId, the raw side's own default), and a non-historized reference registers /// IS the backing raw tag's NodeId, the raw side's own default), and a non-historized reference registers
@@ -1727,6 +1727,35 @@ public sealed class AddressSpaceApplierTests
sink.RemoveCalls.ShouldBeEmpty(); sink.RemoveCalls.ShouldBeEmpty();
} }
/// <summary>Gitea #610: the array-write capability flipping on an already-array tag (its driver gained an
/// array encoder, or the element type moved into the supported set) is applied IN PLACE on the raw node AND
/// its UNS mirror — both become writable — with no rebuild. Without ArrayWritable on the classifier whitelist
/// this edit would tear down every node; without BackingArrayWritable the mirror would stay read-only.</summary>
[Fact]
public void Array_write_capability_flip_updates_raw_and_uns_writability_in_place()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var rawBefore = RawTag("Plant/Modbus/dev1/block") with { Writable = true, IsArray = true, ArrayLength = 8 };
var rawAfter = rawBefore with { ArrayWritable = true };
var unsBefore = UnsRef("filling/line-1/station-1/block", "block") with { BackingIsArray = true, BackingArrayLength = 8 };
var unsAfter = unsBefore with { BackingArrayWritable = true };
var plan = AddressSpacePlanner.Compute(
CompositionWith(rawBefore, unsBefore), CompositionWith(rawAfter, unsAfter));
plan.ChangedRawTags.Count.ShouldBe(1);
plan.ChangedUnsReferenceVariables.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
sink.RemoveCalls.ShouldBeEmpty();
sink.SurgicalCalls.Single(c => c.Realm == AddressSpaceRealm.Raw).Writable.ShouldBeTrue();
sink.SurgicalCalls.Single(c => c.Realm == AddressSpaceRealm.Uns).Writable.ShouldBeTrue();
}
/// <summary>The one-way rebuild ratchet holds for the new raw-tag path: a surgical update that reports the /// <summary>The one-way rebuild ratchet holds for the new raw-tag path: a surgical update that reports the
/// node missing (false) falls back to a full rebuild.</summary> /// node missing (false) falls back to a full rebuild.</summary>
[Fact] [Fact]
@@ -97,7 +97,7 @@ public sealed class AddressSpaceChangeClassifierDualNamespaceTests
} }
/// <summary>The in-place-updatable attribute whitelist (Writable / IsHistorized / HistorianTagname / /// <summary>The in-place-updatable attribute whitelist (Writable / IsHistorized / HistorianTagname /
/// DataType / IsArray / ArrayLength) on a non-alarm raw tag is surgical, not a rebuild.</summary> /// DataType / IsArray / ArrayLength / ArrayWritable) on a non-alarm raw tag is surgical, not a rebuild.</summary>
[Theory] [Theory]
[InlineData("writable")] [InlineData("writable")]
[InlineData("historized")] [InlineData("historized")]
@@ -105,12 +105,14 @@ public sealed class AddressSpaceChangeClassifierDualNamespaceTests
[InlineData("dataType")] [InlineData("dataType")]
[InlineData("array")] [InlineData("array")]
[InlineData("arrayLength")] [InlineData("arrayLength")]
[InlineData("arrayWritable")]
public void Attribute_raw_tag_delta_is_not_a_rebuild(string field) public void Attribute_raw_tag_delta_is_not_a_rebuild(string field)
{ {
var baseline = RawTag("Plant/Modbus1/PLC-A/Speed"); var baseline = RawTag("Plant/Modbus1/PLC-A/Speed");
// ArrayLength moving ALONE (a resize of an already-array tag) — the whitelist must carry ArrayLength // ArrayLength moving ALONE (a resize of an already-array tag) — the whitelist must carry ArrayLength
// in its own right, not only as a passenger of an IsArray flip. Its "previous" is already an array. // in its own right, not only as a passenger of an IsArray flip. Its "previous" is already an array.
var prev = field == "arrayLength" ? baseline with { IsArray = true, ArrayLength = 8 } : baseline; // ArrayWritable (#610) likewise moves on an already-array tag (a driver-type or element-type edit).
var prev = field is "arrayLength" or "arrayWritable" ? baseline with { IsArray = true, ArrayLength = 8 } : baseline;
var cur = field switch var cur = field switch
{ {
"writable" => prev with { Writable = true }, "writable" => prev with { Writable = true },
@@ -119,6 +121,7 @@ public sealed class AddressSpaceChangeClassifierDualNamespaceTests
"dataType" => prev with { DataType = "Double" }, "dataType" => prev with { DataType = "Double" },
"array" => prev with { IsArray = true, ArrayLength = 8 }, "array" => prev with { IsArray = true, ArrayLength = 8 },
"arrayLength" => prev with { ArrayLength = 16 }, "arrayLength" => prev with { ArrayLength = 16 },
"arrayWritable" => prev with { ArrayWritable = true },
_ => throw new ArgumentOutOfRangeException(nameof(field)), _ => throw new ArgumentOutOfRangeException(nameof(field)),
}; };
@@ -269,4 +269,42 @@ public sealed class AddressSpaceComposerDualNamespaceTests
a.RawTags.ShouldBe(b.RawTags); a.RawTags.ShouldBe(b.RawTags);
a.UnsReferenceVariables.ShouldBe(b.UnsReferenceVariables); a.UnsReferenceVariables.ShouldBe(b.UnsReferenceVariables);
} }
/// <summary>Gitea #610: <c>ArrayWritable</c> is derived per OWNING DRIVER at compose time, and the UNS mirror
/// inherits it as <c>BackingArrayWritable</c>. An S7 Int16 array is writable-capable; the identical shape on
/// an AbCip driver (scalar-only write encoder) is not; a scalar is never flagged.</summary>
[Fact]
public void Array_write_capability_follows_the_owning_driver_and_reaches_the_uns_mirror()
{
var folder = new RawFolder { RawFolderId = "f", ClusterId = "c1", Name = "Plant" };
var s7 = new DriverInstance { DriverInstanceId = "drv-s7", ClusterId = "c1", RawFolderId = "f", Name = "S7a", DriverType = "S7", DriverConfig = "{}" };
var ab = new DriverInstance { DriverInstanceId = "drv-ab", ClusterId = "c1", RawFolderId = "f", Name = "Ab1", DriverType = "AbCip", DriverConfig = "{}" };
var d1 = new Device { DeviceId = "d1", DriverInstanceId = "drv-s7", Name = "plc1", DeviceConfig = "{}" };
var d2 = new Device { DeviceId = "d2", DriverInstanceId = "drv-ab", Name = "plc2", DeviceConfig = "{}" };
const string s7Array = "{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":4}";
var tags = new[]
{
new Tag { TagId = "t1", DeviceId = "d1", Name = "Levels", DataType = "Int16", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = s7Array },
new Tag { TagId = "t2", DeviceId = "d1", Name = "Level", DataType = "Int16", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"address\":\"DB1.DBW8\",\"dataType\":\"Int16\"}" },
new Tag { TagId = "t3", DeviceId = "d2", Name = "Counts", DataType = "Int32", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"tagPath\":\"Counts\",\"dataType\":\"DInt\",\"isArray\":true,\"arrayLength\":4}" },
};
var area = new UnsArea { UnsAreaId = "a", ClusterId = "c1", Name = "filling" };
var line = new UnsLine { UnsLineId = "l", UnsAreaId = "a", Name = "line-1" };
var equip = new Equipment { EquipmentId = "e", UnsLineId = "l", Name = "station-1", MachineCode = "S1" };
var refs = new[]
{
new UnsTagReference { UnsTagReferenceId = "r1", EquipmentId = "e", TagId = "t1" },
new UnsTagReference { UnsTagReferenceId = "r3", EquipmentId = "e", TagId = "t3" },
};
var c = AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equip }, new[] { s7, ab }, Array.Empty<ScriptedAlarm>(),
unsTagReferences: refs, tags: tags, rawFolders: new[] { folder }, devices: new[] { d1, d2 });
c.RawTags.Single(t => t.Name == "Levels").ArrayWritable.ShouldBeTrue();
c.RawTags.Single(t => t.Name == "Level").ArrayWritable.ShouldBeFalse(); // scalar: never flagged
c.RawTags.Single(t => t.Name == "Counts").ArrayWritable.ShouldBeFalse(); // AbCip: no array encoder
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r1").BackingArrayWritable.ShouldBeTrue();
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r3").BackingArrayWritable.ShouldBeFalse();
}
} }
@@ -189,6 +189,45 @@ public sealed class EquipmentWriteGateTests
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: "any", node).ShouldBeNull(); OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: "any", node).ShouldBeNull();
} }
// ───────────────────────────── #610 — array shape + IndexRange ─────────────────────────────
/// <summary>A whole array of the declared length proceeds; a wrong length or a scalar is refused inline.</summary>
[Fact]
public void Structure_array_node_takes_only_a_whole_array_of_its_declared_length()
{
var node = ArrayNode(DataTypeIds.Int16, length: 4);
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new short[] { 1, 2, 3, 4 }, node).ShouldBeNull();
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new short[] { 1, 2, 3 }, node)!.StatusCode
.ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure((short)1, node)!.StatusCode
.ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
}
/// <summary>An unbounded array (ArrayDimensions [0]) accepts any length.</summary>
[Fact]
public void Structure_unbounded_array_node_accepts_any_length()
{
var node = ArrayNode(DataTypeIds.Int16, length: 0);
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new short[] { 1, 2, 3 }, node).ShouldBeNull();
}
/// <summary>A partial (IndexRange) write is refused; an absent range proceeds.</summary>
[Fact]
public void Range_only_whole_value_writes_proceed()
{
OtOpcUaNodeManager.EvaluateEquipmentWriteRange(NumericRange.Empty).ShouldBeNull();
OtOpcUaNodeManager.EvaluateEquipmentWriteRange(NumericRange.Parse("1"))!.StatusCode
.ShouldBe((StatusCode)StatusCodes.BadWriteNotSupported);
}
private static BaseDataVariableState ArrayNode(NodeId dataType, uint length)
{
var node = ValueNode(dataType);
node.ValueRank = ValueRanks.OneDimension;
node.ArrayDimensions = new ReadOnlyList<uint>(new UInt32Collection(new[] { length }));
return node;
}
private static BaseDataVariableState ValueNode(NodeId dataType) => new(null) private static BaseDataVariableState ValueNode(NodeId dataType) => new(null)
{ {
NodeId = new NodeId("eq/x", 2), NodeId = new NodeId("eq/x", 2),
@@ -86,7 +86,7 @@ public sealed class UnsReferenceBackingParityTests
{ {
nameof(RawTagPlan.DataType), nameof(RawTagPlan.Writable), nameof(RawTagPlan.ReferencingEquipmentPaths), nameof(RawTagPlan.DataType), nameof(RawTagPlan.Writable), nameof(RawTagPlan.ReferencingEquipmentPaths),
nameof(RawTagPlan.IsHistorized), nameof(RawTagPlan.HistorianTagname), nameof(RawTagPlan.IsArray), nameof(RawTagPlan.IsHistorized), nameof(RawTagPlan.HistorianTagname), nameof(RawTagPlan.IsArray),
nameof(RawTagPlan.ArrayLength), nameof(RawTagPlan.ArrayLength), nameof(RawTagPlan.ArrayWritable),
}, },
ignoreOrder: true); ignoreOrder: true);
@@ -49,7 +49,7 @@ public sealed class DeploymentArtifactRawUnsParityTests
var profile = new Tag var profile = new Tag
{ {
TagId = "tag-profile", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Profile", DataType = "Float", TagId = "tag-profile", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Profile", DataType = "Float",
AccessLevel = TagAccessLevel.Read, TagConfig = "{\"isArray\":true,\"arrayLength\":16}", AccessLevel = TagAccessLevel.Read, TagConfig = "{\"region\":\"HoldingRegisters\",\"address\":100,\"dataType\":\"Float32\",\"isArray\":true,\"arrayLength\":16}",
}; };
var tempHi = new Tag var tempHi = new Tag
{ {
@@ -82,7 +82,7 @@ public sealed class DeploymentArtifactRawUnsParityTests
{ {
new { TagId = "tag-speed", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Speed", DataType = "Float", AccessLevel = 1, TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"WW.Speed\"}" }, new { TagId = "tag-speed", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Speed", DataType = "Float", AccessLevel = 1, TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"WW.Speed\"}" },
new { TagId = "tag-run", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "Run", DataType = "Boolean", AccessLevel = 0, TagConfig = "{}" }, new { TagId = "tag-run", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "Run", DataType = "Boolean", AccessLevel = 0, TagConfig = "{}" },
new { TagId = "tag-profile", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Profile", DataType = "Float", AccessLevel = 0, TagConfig = "{\"isArray\":true,\"arrayLength\":16}" }, new { TagId = "tag-profile", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Profile", DataType = "Float", AccessLevel = 0, TagConfig = "{\"region\":\"HoldingRegisters\",\"address\":100,\"dataType\":\"Float32\",\"isArray\":true,\"arrayLength\":16}" },
new { TagId = "tag-temphi", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "TempHi", DataType = "Boolean", AccessLevel = 1, TagConfig = "{\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}" }, new { TagId = "tag-temphi", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "TempHi", DataType = "Boolean", AccessLevel = 1, TagConfig = "{\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}" },
}, },
UnsAreas = new[] { new { UnsAreaId = "a1", Name = "filling", ClusterId = "c1" } }, UnsAreas = new[] { new { UnsAreaId = "a1", Name = "filling", ClusterId = "c1" } },
@@ -106,6 +106,7 @@ public sealed class DeploymentArtifactRawUnsParityTests
var composedArray = composed.RawTags.Single(t => t.Name == "Profile"); var composedArray = composed.RawTags.Single(t => t.Name == "Profile");
composedArray.IsArray.ShouldBeTrue(); composedArray.IsArray.ShouldBeTrue();
composedArray.ArrayLength.ShouldBe(16u); composedArray.ArrayLength.ShouldBe(16u);
composedArray.ArrayWritable.ShouldBeTrue(); // #610: a Modbus holding-register Float32 array is writable
var composedAlarm = composed.RawTags.Single(t => t.Name == "TempHi"); var composedAlarm = composed.RawTags.Single(t => t.Name == "TempHi");
composedAlarm.Alarm.ShouldNotBeNull(); composedAlarm.Alarm.ShouldNotBeNull();
composedAlarm.Alarm!.AlarmType.ShouldBe("OffNormalAlarm"); composedAlarm.Alarm!.AlarmType.ShouldBe("OffNormalAlarm");
@@ -123,6 +124,7 @@ public sealed class DeploymentArtifactRawUnsParityTests
var composedProfileRef = composed.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-2"); var composedProfileRef = composed.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-2");
composedProfileRef.BackingIsArray.ShouldBeTrue(); composedProfileRef.BackingIsArray.ShouldBeTrue();
composedProfileRef.BackingArrayLength.ShouldBe(16u); composedProfileRef.BackingArrayLength.ShouldBe(16u);
composedProfileRef.BackingArrayWritable.ShouldBeTrue();
composedProfileRef.BackingIsHistorized.ShouldBeFalse(); composedProfileRef.BackingIsHistorized.ShouldBeFalse();
// Byte-parity: the three dual-namespace node sets are record-equal between the two seams. // Byte-parity: the three dual-namespace node sets are record-equal between the two seams.