feat(address-space): server-side array writes gated per driver capability (#610)
Array nodes were forced read-only for every driver. A new single authority, Core.Abstractions DriverArrayWriteSupport, says which (driver, element type) pairs encode a whole-array write: Modbus (Coils / numeric HoldingRegisters), S7 (Byte/Int16/UInt16/Int32/UInt32/Float32), OpcUaClient (pass-through) and Galaxy (Boolean/Int32/Int64/Float/Double/String/DateTime). Everything else stays read-only, so the server never advertises a write the driver refuses. - Composer derives RawTagPlan.ArrayWritable + UnsReferenceVariable .BackingArrayWritable at compose time (both seams, byte-parity); the applier's one writability rule is Writable && (!IsArray || ArrayWritable) for materialise and surgical paths, raw and UNS alike. - ArrayWritable is on the classifier's surgical raw-tag whitelist and mirrored on the UNS record (UnsReferenceBackingParityTests updated). - Node manager refuses a wrong-length / non-array value on an array node (BadTypeMismatch) and any IndexRange write (BadWriteNotSupported) inline, before dispatch; WriteOperate/ACL gates and the revert are unchanged. - Per-driver parity tests pin the table against the real Modbus write path, S7 EncodeArrayBlock and Galaxy MxValueEncoder (UInt32/UInt64 excluded: array covariance reinterprets their bits). - Over-the-wire proof in OpcUaServer.IntegrationTests (ArrayWriteWireTests). - ValueRank > 1 documented as out of scope by decision (docs/Uns.md). Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+3
-2
@@ -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
@@ -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
@@ -253,12 +253,13 @@ decode), and the value must be an array of exactly `arrayLength` elements (else
|
|||||||
health impact). **Writable `Bool` arrays are refused at init** (`NotSupportedException`): the array
|
health impact). **Writable `Bool` arrays are refused at init** (`NotSupportedException`): the array
|
||||||
read is byte-granular, so a matching write would overwrite the 7 neighbouring bits of every byte it
|
read is byte-granular, so a matching write would overwrite the 7 neighbouring bits of every byte it
|
||||||
touches, and a safe bit-packed write needs read-modify-write. Mark Bool arrays `writable: false`.
|
touches, and a safe bit-packed write needs read-modify-write. Mark Bool arrays `writable: false`.
|
||||||
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 > 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 && (!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));
|
||||||
|
|||||||
@@ -1940,7 +1940,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
|
||||||
@@ -2057,6 +2057,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.
|
||||||
@@ -2084,6 +2103,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>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
@@ -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]
|
||||||
|
|||||||
+5
-2
@@ -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)),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+38
@@ -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);
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user