fix(address-space): numeric array writes convert (checked) to the node element type; array-to-scalar refusal only for Modbus/S7 (#610 review R6)
MAJOR-1: R4's exact element-type match refused ScadaBridge's CLR-list writes (its element types are only Boolean/Int32/Float/Double/String/ DateTime) to every Int16/UInt16/Byte/UInt32/Int64/UInt64 array node and a Double list to a Float array. EvaluateEquipmentWriteStructure now converts a numeric array of another numeric element type element-wise, CHECKED, into the node's element type and hands the converted array back through OnEquipmentTagWrite's ref value, so the node stores and the driver receives the node's own type. Any out-of-range, fractional-to-integer, NaN/Inf-to- integer or Float-overflowing element refuses the whole write BadTypeMismatch (nothing dispatched) - so the UInt32 -> Int32 bit reinterpretation stays closed. Non-numeric mismatches stay refused. The wrong 'no client relies on a looser rule' comment is corrected. MINOR-1: the array-value-to-scalar-node refusal broke writes back to browse-committed array tags (committed scalar, isArray never set) on pass-through drivers. It now applies only when the owning driver's scalar encoder cannot take an array (DriverArrayWriteSupport. RefusesArrayValueOnScalarTag: Modbus, S7), carried RawTagPlan. ScalarRefusesArrayValue / UnsReferenceVariable.BackingScalarRefusesArrayValue -> EnsureVariable(refuseArrayOnScalar) -> a per-node set in the node manager. OpcUaClient/Galaxy/others keep pre-#610 dispatch. Not on the classifier whitelist (driver-type edit rebuilds); a mirror whose flag changes is recreated, not updated in place. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+31
-12
@@ -343,17 +343,34 @@ 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 — reachable only on
|
||||
OpcUaClient); anything else is `BadTypeMismatch`, refused inline before dispatch.
|
||||
- **The element type must match the node's DataType exactly** — an `Int32[]` to an `Int16` array,
|
||||
a `UInt32[]` to an `Int32` array or a `Double[]` to a `Float` array is `BadTypeMismatch`, refused
|
||||
inline (the one equivalence is a `Byte` array, which arrives as a ByteString). This is the
|
||||
check the OPC UA SDK would normally make itself: it skips its own type check on any node with an
|
||||
`OnWriteValue` handler, which every writable equipment node has, so the node manager makes it
|
||||
instead. Without it a `UInt32[]` reached Galaxy's `int[]` encoder by CLR array covariance and was
|
||||
written bit-reinterpreted (4,000,000,000 → −294,967,296) with a Good result. **Scalar** writes keep
|
||||
their looser rule on purpose — any numeric to any numeric node — because consumers (ScadaBridge)
|
||||
write CLR-typed values such as a `Double` to a `Float` node, and every driver's scalar encoder
|
||||
converts with a range check, so an out-of-range scalar fails the device write and is reverted.
|
||||
- An **array value to a scalar node** is likewise `BadTypeMismatch`, refused inline.
|
||||
- **The element type must match the node's DataType, or be a numeric type that converts to it
|
||||
exactly.** A matching element type (or a `Byte` array, which arrives as a ByteString) is dispatched
|
||||
as written. A **numeric** array of another numeric element type is **converted, checked, element by
|
||||
element** into the node's element type, and the converted array is what the node stores and the
|
||||
driver receives — an `Int32[]` written to an `Int16` array reaches the driver as `short[]`. The whole
|
||||
write is refused `BadTypeMismatch` inline, nothing dispatched, if **any** element is out of the
|
||||
target's range, fractional for an integer node (`1.5` → `Int16` refused; `2.0` → `2`), NaN/±∞ for an
|
||||
integer node, or finite but beyond `Float` range for a `Float` node. Every **non-numeric** mismatch
|
||||
(`Boolean[]` → `Int32`, `Int32[]` → `String`, a `Variant[]`) is refused. This is the check the OPC UA
|
||||
SDK would normally make itself: it skips its own type check on any node with an `OnWriteValue`
|
||||
handler, which every writable equipment node has, so the node manager makes it instead. Without it a
|
||||
`UInt32[]` reached Galaxy's `int[]` encoder by CLR array covariance and was written bit-reinterpreted
|
||||
(4,000,000,000 → −294,967,296) with a Good result — that write is still refused (4,000,000,000
|
||||
overflows `Int32`), while an in-range `UInt32[]` converts. The conversion is required, not a
|
||||
nicety: ScadaBridge writes the CLR list it decoded from a `List` attribute, and its element types are
|
||||
only Boolean/Int32/Float/Double/String/DateTime, so an exact-match rule left every `Int16`/`UInt16`/
|
||||
`Byte`/`UInt32`/`Int64`/`UInt64` array node (Modbus's default `Int16` included) and a `Double` list to
|
||||
a `Float` array unwritable from it. **Scalar** writes keep their looser rule — any numeric to any
|
||||
numeric node, unconverted — because every driver's scalar encoder converts with a range check, so an
|
||||
out-of-range scalar fails the device write and is reverted.
|
||||
- An **array value to a scalar node** is refused `BadTypeMismatch` inline **only on Modbus and S7**,
|
||||
whose scalar encoders cannot take one (`DriverArrayWriteSupport.RefusesArrayValueOnScalarTag`). Every
|
||||
other driver keeps the pre-#610 behaviour — the write is dispatched and the driver decides. That
|
||||
matters for browse-committed tags: the `/raw` commit sets the DataType but never `isArray`, so an
|
||||
upstream array variable (OpcUaClient) or a Galaxy array attribute committed from the browser is a
|
||||
**scalar** node into which the driver publishes an array, and writing that array back still works
|
||||
(OpcUaClient forwards it to the upstream server, which arbitrates the shape; Galaxy encodes it). Set
|
||||
`isArray` + `arrayLength` on such a tag to get the array-node checks above.
|
||||
- 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.
|
||||
@@ -362,7 +379,9 @@ What a client sees on a writable array node:
|
||||
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`.
|
||||
the classifier's raw-tag whitelist, and the mirrors repair through `BackingArrayWritable`. A
|
||||
change to the array-on-scalar refusal (`ScalarRefusesArrayValue` — only a driver-type edit can flip
|
||||
it) is deliberately NOT whitelisted and rebuilds.
|
||||
|
||||
> ⚠️ **Upgrade note — array tags become client-writable without a deploy.** Before #610 every
|
||||
> array node was read-only regardless of its authored `AccessLevel`. On the first boot after
|
||||
|
||||
@@ -116,4 +116,4 @@ The session-less alarm feed (`GatewayGalaxyAlarmFeed`) and alarm acknowledger (`
|
||||
- **Duplicate `attributeRef` (#605 / 06-S-13)**: two authored RawPaths dialling the same Galaxy attribute are both dialled, but the reverse map is first-wins, so value fan-out and alarm `ConditionId` surface under the first RawPath only. The loser is logged at Warning and recorded in the skipped-tag ledger with the winner path in the reason.
|
||||
- **Subscribe-once reads are bounded (#605 / 06-S-3)**: `ReadAsync` on the production path (SubscribeBulk, first event, Unsubscribe) carries an internal 30 s deadline in addition to the caller token; slots that never publish come back `BadTimeout`, so `CancellationToken.None` can no longer hang on a silent tag.
|
||||
- **Supervisory advise is retried after a thrown failure (#605 / 06-S-12)**: the writer marks an item handle advised before the call; a thrown advise (not just a non-OK status) now unmarks it, fails the write closed and re-advises on the next write.
|
||||
- **Array writes (#610)**: a Galaxy array tag is client-writable only for the `MxValueEncoder` array types (OPC UA `Boolean`/`Int32`/`Int64`/`Float`/`Double`/`String`/`DateTime`) **and only with a declared `arrayLength`** — with none the node is unbounded, and a short whole-array write would *resize* the attribute. The element type must match the node exactly (`BadTypeMismatch` inline otherwise), and `MxValueEncoder` itself refuses `uint[]`/`ulong[]`: CLR array covariance let them match the `int[]`/`long[]` arms and be written bit-reinterpreted. Encoder-level only — Galaxy array writes have **no live gate** yet. See [Uns.md §Array writes](../Uns.md#array-writes-610), including the upgrade note.
|
||||
- **Array writes (#610)**: a Galaxy array tag is client-writable only for the `MxValueEncoder` array types (OPC UA `Boolean`/`Int32`/`Int64`/`Float`/`Double`/`String`/`DateTime`) **and only with a declared `arrayLength`** — with none the node is unbounded, and a short whole-array write would *resize* the attribute. The element type must match the node or be a numeric type that converts to it exactly — a numeric array of another element type is converted, checked, to the node's type in the node manager (an `Int32` list to an `Int64` array arrives as `long[]`), and any unrepresentable element refuses the write `BadTypeMismatch` inline. An array written to a **scalar** Galaxy node (a browse-committed array attribute without `isArray`) is still dispatched, as before #610. Separately, `MxValueEncoder` itself refuses `uint[]`/`ulong[]`: CLR array covariance let them match the `int[]`/`long[]` arms and be written bit-reinterpreted. Encoder-level only — Galaxy array writes have **no live gate** yet. See [Uns.md §Array writes](../Uns.md#array-writes-610), including the upgrade note.
|
||||
|
||||
@@ -63,8 +63,8 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
||||
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
||||
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength);
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
|
||||
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength, refuseArrayOnScalar);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
|
||||
|
||||
@@ -172,7 +172,12 @@ public interface IOpcUaAddressSpaceSink
|
||||
/// <param name="realm">The namespace realm the variable (and its <paramref name="parentFolderNodeId"/>) live
|
||||
/// in. A historized UNS reference node registers the SAME <paramref name="historianTagname"/> as its backing
|
||||
/// raw node — both NodeIds map to one tagname — so this call carries the shared tagname per realm.</param>
|
||||
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null);
|
||||
/// <param name="refuseArrayOnScalar">Gitea #610 review (R6): when true and the node is SCALAR, a client write
|
||||
/// of an array value is refused inline (<c>BadTypeMismatch</c>, nothing dispatched) because the owning
|
||||
/// driver's scalar encoder cannot take one (Modbus / S7). False (default) keeps the pre-#610 behaviour — the
|
||||
/// write is dispatched and the driver decides (OpcUaClient forwards it to the upstream server, Galaxy encodes
|
||||
/// it). Fixed at creation; an existing node keeps the value it was created with.</param>
|
||||
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false);
|
||||
|
||||
/// <summary>
|
||||
/// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> after a
|
||||
@@ -273,7 +278,7 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RebuildAddressSpace() { }
|
||||
|
||||
@@ -124,6 +124,26 @@ public static class DriverArrayWriteSupport
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #610 review (R6 MINOR-1) — whether a driver of <paramref name="driverType"/> is known to REFUSE an
|
||||
/// array value written to one of its SCALAR tags, so the node manager may refuse such a write inline
|
||||
/// (<c>BadTypeMismatch</c>, nothing dispatched) instead of storing it optimistically, dispatching it, and
|
||||
/// reverting it when the scalar encoder throws.
|
||||
/// <para>
|
||||
/// Only Modbus and S7 qualify: their scalar encoders <c>Convert.ToXxx</c> the value, which throws on an
|
||||
/// array. Every other driver keeps the pre-#610 behaviour — the write is dispatched and the driver
|
||||
/// decides. That matters for the pass-through drivers: a browse-committed array attribute is committed
|
||||
/// as a SCALAR tag (the <c>/raw</c> commit never sets <c>isArray</c>), the driver publishes an array into
|
||||
/// it, and writing that array back worked before #610 — <c>OpcUaClient</c> forwards the
|
||||
/// <c>Variant</c> unchanged (the upstream server arbitrates shape) and Galaxy's <c>MxValueEncoder</c>
|
||||
/// encodes the array. An unknown / null driver type is not listed ⇒ <see langword="false"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="driverType">The owning driver instance's <c>DriverType</c>.</param>
|
||||
/// <returns><see langword="true"/> only when the driver's scalar write path cannot take an array value.</returns>
|
||||
public static bool RefusesArrayValueOnScalarTag(string? driverType) =>
|
||||
driverType is DriverTypeNames.Modbus or DriverTypeNames.S7;
|
||||
|
||||
// The smallest Modbus write cap configured in any config; none configured (absent / non-positive) ⇒ the driver
|
||||
// default (123). The key is matched case-insensitively, like the driver's DTO binding.
|
||||
private static int ModbusMaxRegistersPerWrite(string?[]? driverConfigs)
|
||||
|
||||
@@ -54,8 +54,8 @@ internal static class MxValueEncoder
|
||||
default:
|
||||
throw new ArgumentException(
|
||||
$"Cannot encode value of type {value.GetType()} as MxValue. Supported: " +
|
||||
"bool, int / long (and their unsigned variants), float, double, string, DateTime, " +
|
||||
"and their 1-D array variants.",
|
||||
"bool, int / long (and, as scalars only, their unsigned variants), float, double, string, " +
|
||||
"DateTime, and the 1-D arrays of the signed / non-integer types (no uint[] / ulong[]).",
|
||||
nameof(value));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,7 +485,10 @@ public sealed class AddressSpaceApplier
|
||||
private static bool IsIdentityPreservingUnsRefDelta(AddressSpacePlan.UnsReferenceDelta d) =>
|
||||
string.Equals(d.Previous.NodeId, d.Current.NodeId, StringComparison.Ordinal) &&
|
||||
string.Equals(d.Previous.EquipmentId, d.Current.EquipmentId, StringComparison.Ordinal) &&
|
||||
string.Equals(d.Previous.BackingRawPath, d.Current.BackingRawPath, StringComparison.Ordinal);
|
||||
string.Equals(d.Previous.BackingRawPath, d.Current.BackingRawPath, StringComparison.Ordinal) &&
|
||||
// #610 review R6: the array-on-scalar refusal is fixed at node creation (UpdateTagAttributes does not
|
||||
// carry it), so a change to it recreates the mirror rather than updating it in place.
|
||||
d.Previous.BackingScalarRefusesArrayValue == d.Current.BackingScalarRefusesArrayValue;
|
||||
|
||||
/// <summary>Remove a value-variable node in place, treating a throw as a false (⇒ rebuild fallback).</summary>
|
||||
/// <returns><c>true</c> when the node was removed; <c>false</c> when unknown or the sink threw.</returns>
|
||||
@@ -993,7 +996,7 @@ public sealed class AddressSpaceApplier
|
||||
? (string.IsNullOrWhiteSpace(t.HistorianTagname) ? t.NodeId : t.HistorianTagname)
|
||||
: null;
|
||||
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, t.ScalarRefusesArrayValue)) failed++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1050,7 +1053,7 @@ public sealed class AddressSpaceApplier
|
||||
? (string.IsNullOrWhiteSpace(v.BackingHistorianTagname) ? v.BackingRawPath : v.BackingHistorianTagname)
|
||||
: null;
|
||||
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, v.BackingScalarRefusesArrayValue))
|
||||
{
|
||||
failed++;
|
||||
continue; // node not created — skip the Organizes edge (a missing endpoint would no-op anyway)
|
||||
@@ -1276,9 +1279,9 @@ public sealed class AddressSpaceApplier
|
||||
/// on success, <c>false</c> when the sink threw — callers tally the false into their pass's failed-node
|
||||
/// count (archreview 01/S-1).</summary>
|
||||
/// <returns><c>true</c> when the variable was ensured; <c>false</c> when the sink threw.</returns>
|
||||
private bool SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
||||
private bool SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
|
||||
{
|
||||
try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength); return true; }
|
||||
try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength, refuseArrayOnScalar); return true; }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureVariable threw for {Node}", nodeId); return false; }
|
||||
}
|
||||
|
||||
|
||||
@@ -143,6 +143,13 @@ public sealed record RawContainerNode(
|
||||
/// <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.
|
||||
/// <see cref="ScalarRefusesArrayValue"/> (#610 review R6) is true when the owning driver's SCALAR write path
|
||||
/// cannot take an array value
|
||||
/// (<see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverArrayWriteSupport.RefusesArrayValueOnScalarTag"/>);
|
||||
/// the node manager refuses an array write to the node inline while it is scalar-shaped. It is a fact about
|
||||
/// the DRIVER, deliberately not gated on <see cref="IsArray"/>, so a surgical scalar↔array edit does not
|
||||
/// flip it. It is not in the classifier's surgical whitelist, so a change to it (a driver's type edited in
|
||||
/// place) rebuilds.
|
||||
/// <para><b>ADDING A FIELD IS A FOUR-PLACE CONTRACT — the hand-written equality below does NOT close
|
||||
/// over new members.</b> Because <see cref="ReferencingEquipmentPaths"/> forces a custom
|
||||
/// <c>Equals</c>/<c>GetHashCode</c>, the compiler's positional equality is suppressed, so a field added
|
||||
@@ -170,7 +177,8 @@ public sealed record RawTagPlan(
|
||||
bool IsArray = false,
|
||||
uint? ArrayLength = null,
|
||||
AddressSpaceRealm Realm = AddressSpaceRealm.Raw,
|
||||
bool ArrayWritable = false)
|
||||
bool ArrayWritable = false,
|
||||
bool ScalarRefusesArrayValue = false)
|
||||
{
|
||||
/// <inheritdoc />
|
||||
// Structural equality: the auto-generated record equality compares ReferencingEquipmentPaths (an
|
||||
@@ -193,6 +201,7 @@ public sealed record RawTagPlan(
|
||||
ArrayLength == other.ArrayLength &&
|
||||
Realm == other.Realm &&
|
||||
ArrayWritable == other.ArrayWritable &&
|
||||
ScalarRefusesArrayValue == other.ScalarRefusesArrayValue &&
|
||||
ReferencingEquipmentPaths.SequenceEqual(other.ReferencingEquipmentPaths, StringComparer.Ordinal);
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -202,7 +211,7 @@ public sealed record RawTagPlan(
|
||||
hash.Add(TagId); hash.Add(NodeId); hash.Add(ParentNodeId); hash.Add(DriverInstanceId);
|
||||
hash.Add(Name); hash.Add(DataType); hash.Add(Writable); hash.Add(Alarm);
|
||||
hash.Add(IsHistorized); hash.Add(HistorianTagname); hash.Add(IsArray); hash.Add(ArrayLength);
|
||||
hash.Add(Realm); hash.Add(ArrayWritable);
|
||||
hash.Add(Realm); hash.Add(ArrayWritable); hash.Add(ScalarRefusesArrayValue);
|
||||
foreach (var p in ReferencingEquipmentPaths) hash.Add(p, StringComparer.Ordinal);
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
@@ -236,7 +245,10 @@ public sealed record RawTagPlan(
|
||||
/// <see cref="DataType"/> / <see cref="Writable"/> were already inherited onto the record and are
|
||||
/// 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>
|
||||
/// is), and <see cref="BackingScalarRefusesArrayValue"/> (#610 review R6 — the backing tag's
|
||||
/// <see cref="RawTagPlan.ScalarRefusesArrayValue"/>, so a mirror refuses an array write exactly when its raw
|
||||
/// node does; a change to it is treated as an identity change by the applier, so the mirror is recreated
|
||||
/// rather than updated in place).</para>
|
||||
/// <para>Equality is the COMPILER-GENERATED positional-record equality: every member is a
|
||||
/// value/string type (no <c>IReadOnlyList</c> member, unlike <see cref="RawTagPlan"/>), so the four
|
||||
/// new fields are compared by value with no hand-written <c>Equals</c>/<c>GetHashCode</c> needed. If
|
||||
@@ -256,7 +268,8 @@ public sealed record UnsReferenceVariable(
|
||||
bool BackingIsArray = false,
|
||||
uint? BackingArrayLength = null,
|
||||
AddressSpaceRealm Realm = AddressSpaceRealm.Uns,
|
||||
bool BackingArrayWritable = false);
|
||||
bool BackingArrayWritable = false,
|
||||
bool BackingScalarRefusesArrayValue = false);
|
||||
|
||||
public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName);
|
||||
public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, string DisplayName);
|
||||
@@ -669,7 +682,8 @@ public static class AddressSpaceComposer
|
||||
unsAreas, unsLines, equipment, topology);
|
||||
var unsReferenceVariables = BuildUnsReferenceVariables(
|
||||
unsTagReferences, tags, unsAreas, unsLines, equipment, topology,
|
||||
BuildArrayWriteCapability(driverInstances, devices));
|
||||
BuildArrayWriteCapability(driverInstances, devices),
|
||||
BuildScalarArrayRefusal(driverInstances, devices));
|
||||
|
||||
return new AddressSpaceComposition(areas, lines, nodes, plans, alarms)
|
||||
{
|
||||
@@ -769,6 +783,7 @@ public static class AddressSpaceComposer
|
||||
// native-alarm multi-notifier. Distinct + ordinal-sorted for determinism.
|
||||
var equipPathByTag = BuildReferencingEquipmentPathsByTag(unsTagReferences, unsAreas, unsLines, equipment);
|
||||
var arrayWritableFor = BuildArrayWriteCapability(driverInstances, devices);
|
||||
var scalarRefusesArrayFor = BuildScalarArrayRefusal(driverInstances, devices);
|
||||
|
||||
var rawTags = new List<RawTagPlan>();
|
||||
foreach (var t in tags ?? Array.Empty<Tag>())
|
||||
@@ -781,6 +796,7 @@ public static class AddressSpaceComposer
|
||||
var driverInstanceId = topology.DriverIdByDevice.GetValueOrDefault(t.DeviceId, string.Empty);
|
||||
var intent = TagConfigIntent.Parse(t.TagConfig);
|
||||
var arrayWritable = intent.IsArray && arrayWritableFor(t);
|
||||
var scalarRefusesArray = scalarRefusesArrayFor(t);
|
||||
var alarm = intent.Alarm is { } a
|
||||
? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva)
|
||||
: null;
|
||||
@@ -800,7 +816,8 @@ public static class AddressSpaceComposer
|
||||
HistorianTagname: intent.HistorianTagname,
|
||||
IsArray: intent.IsArray,
|
||||
ArrayLength: intent.ArrayLength,
|
||||
ArrayWritable: arrayWritable));
|
||||
ArrayWritable: arrayWritable,
|
||||
ScalarRefusesArrayValue: scalarRefusesArray));
|
||||
}
|
||||
|
||||
containers.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
|
||||
@@ -817,6 +834,32 @@ public static class AddressSpaceComposer
|
||||
/// </summary>
|
||||
private static Func<Tag, bool> BuildArrayWriteCapability(
|
||||
IReadOnlyList<DriverInstance> driverInstances, IReadOnlyList<Device>? devices)
|
||||
{
|
||||
var resolve = BuildTagOwnerResolver(driverInstances, devices);
|
||||
return tag =>
|
||||
{
|
||||
var (driver, device) = resolve(tag);
|
||||
return DriverArrayWriteSupport.Supports(
|
||||
driver?.DriverType, tag.DataType, tag.TagConfig, driver?.DriverConfig, device?.DeviceConfig);
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #610 review R6 MINOR-1 — whether a tag's owning driver refuses an array value on a SCALAR tag
|
||||
/// (<see cref="DriverArrayWriteSupport.RefusesArrayValueOnScalarTag"/>), as a per-tag predicate resolved
|
||||
/// the same way as <see cref="BuildArrayWriteCapability"/>. Shared by the raw and UNS passes so a mirror
|
||||
/// refuses exactly when its raw node does.
|
||||
/// </summary>
|
||||
private static Func<Tag, bool> BuildScalarArrayRefusal(
|
||||
IReadOnlyList<DriverInstance> driverInstances, IReadOnlyList<Device>? devices)
|
||||
{
|
||||
var resolve = BuildTagOwnerResolver(driverInstances, devices);
|
||||
return tag => DriverArrayWriteSupport.RefusesArrayValueOnScalarTag(resolve(tag).Driver?.DriverType);
|
||||
}
|
||||
|
||||
// Tag → (owning driver, owning device); first row wins on a duplicate id, matching the composer's other id maps.
|
||||
private static Func<Tag, (DriverInstance? Driver, Device? Device)> BuildTagOwnerResolver(
|
||||
IReadOnlyList<DriverInstance> driverInstances, IReadOnlyList<Device>? devices)
|
||||
{
|
||||
var driverById = driverInstances
|
||||
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
|
||||
@@ -828,8 +871,7 @@ public static class AddressSpaceComposer
|
||||
{
|
||||
var device = deviceById.GetValueOrDefault(tag.DeviceId);
|
||||
var driver = device is null ? null : driverById.GetValueOrDefault(device.DriverInstanceId);
|
||||
return DriverArrayWriteSupport.Supports(
|
||||
driver?.DriverType, tag.DataType, tag.TagConfig, driver?.DriverConfig, device?.DeviceConfig);
|
||||
return (driver, device);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -847,7 +889,8 @@ public static class AddressSpaceComposer
|
||||
IReadOnlyList<UnsLine> unsLines,
|
||||
IReadOnlyList<Equipment> equipment,
|
||||
RawTopology topology,
|
||||
Func<Tag, bool> arrayWritableFor)
|
||||
Func<Tag, bool> arrayWritableFor,
|
||||
Func<Tag, bool> scalarRefusesArrayFor)
|
||||
{
|
||||
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
|
||||
if (refs.Count == 0) return Array.Empty<UnsReferenceVariable>();
|
||||
@@ -884,6 +927,7 @@ public static class AddressSpaceComposer
|
||||
// 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 && arrayWritableFor(tag);
|
||||
var backingScalarRefusesArray = scalarRefusesArrayFor(tag);
|
||||
|
||||
vars.Add(new UnsReferenceVariable(
|
||||
UnsTagReferenceId: r.UnsTagReferenceId,
|
||||
@@ -897,7 +941,8 @@ public static class AddressSpaceComposer
|
||||
BackingHistorianTagname: intent.HistorianTagname,
|
||||
BackingIsArray: intent.IsArray,
|
||||
BackingArrayLength: intent.ArrayLength,
|
||||
BackingArrayWritable: backingArrayWritable));
|
||||
BackingArrayWritable: backingArrayWritable,
|
||||
BackingScalarRefusesArrayValue: backingScalarRefusesArray));
|
||||
}
|
||||
|
||||
vars.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
@@ -69,6 +70,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// (later) HistoryRead override resolves a HistoryRead request's NodeId against this map. Cleared on
|
||||
/// <see cref="RebuildAddressSpace"/>.</summary>
|
||||
private readonly ConcurrentDictionary<RealmKey, string> _historizedTagnames = new();
|
||||
/// <summary>Gitea #610 review (R6 MINOR-1): the variables whose owning driver cannot write an array value to a
|
||||
/// SCALAR tag (Modbus / S7 — <c>DriverArrayWriteSupport.RefusesArrayValueOnScalarTag</c>), so an array write to
|
||||
/// one while it is scalar-shaped is refused inline. Every other scalar node keeps the pre-#610 behaviour (the
|
||||
/// write is dispatched and the driver decides). Set by <see cref="EnsureVariable"/>; cleared alongside
|
||||
/// <see cref="_variables"/>.</summary>
|
||||
private readonly ConcurrentDictionary<RealmKey, byte> _arrayOnScalarRefused = new();
|
||||
/// <summary>Folders we have already promoted to event-notifiers + registered as root notifiers,
|
||||
/// so repeated <see cref="MaterialiseAlarmCondition"/> calls don't double-add (idempotent guard).
|
||||
/// Keyed by NodeId → the actual <see cref="FolderState"/> so <see cref="RebuildAddressSpace"/> can
|
||||
@@ -1921,8 +1928,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// synchronously so the SDK never applies it (no optimistic-Good-then-revert round-trip + no needless
|
||||
// device dispatch). Runs AFTER the authz gate (so we never leak structure detail to an unauthorised
|
||||
// caller) but BEFORE the optimistic dispatch below.
|
||||
var structure = EvaluateEquipmentWriteRange(indexRange) ?? EvaluateEquipmentWriteStructure(value, node);
|
||||
// A numeric array of another element type is converted (checked) to the node's element type here and
|
||||
// the converted array replaces `value`, so the SDK stores — and the driver receives — the node's own type.
|
||||
var refuseArrayOnScalar = _arrayOnScalarRefused.ContainsKey(MapKey(node.NodeId));
|
||||
var range = EvaluateEquipmentWriteRange(indexRange);
|
||||
if (range is not null) return range;
|
||||
var structure = EvaluateEquipmentWriteStructure(value, node, refuseArrayOnScalar, out var coerced);
|
||||
if (structure is not null) return structure;
|
||||
value = coerced!;
|
||||
|
||||
// Capture the optimistic value + the REAL prior value/status BEFORE the SDK applies the write
|
||||
// (at handler entry the node still holds the prior value; returning Good makes the SDK apply `value`).
|
||||
@@ -2025,18 +2038,45 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// <c>BaseVariableState.WriteValueAttribute</c> runs <c>TypeInfo.IsInstanceOfDataType</c> only when
|
||||
/// the node has NO <c>OnWriteValue</c> handler; with one (every writable equipment node) it checks the
|
||||
/// access level, calls the handler and stores the value as given. So: an ARRAY write is matched
|
||||
/// EXACTLY (shape, declared length and element type — the SDK's own rule, restored), and an array
|
||||
/// value to a scalar node is refused. A SCALAR write keeps the looser numeric rule above on purpose
|
||||
/// (see <see cref="IsBuiltInTypeCompatible"/>).
|
||||
/// by shape and declared length, and its element type must either match the node's DataType or be
|
||||
/// CONVERTIBLE to it: a numeric array of another numeric element type is converted element-wise, CHECKED,
|
||||
/// into the node's element type (<see cref="TryConvertNumericArray"/>) — refused whole if any element is
|
||||
/// out of range, fractional for an integer node, or NaN/±Infinity for an integer node — and every other
|
||||
/// mismatch is refused. An array value to a scalar node is refused only when
|
||||
/// <paramref name="refuseArrayOnScalar"/> says the owning driver cannot take one. A SCALAR write keeps
|
||||
/// the looser numeric rule above on purpose (see <see cref="IsBuiltInTypeCompatible"/>).
|
||||
/// </para>
|
||||
/// Pure (no SDK server / Lock needed): reads only <paramref name="value"/> and the node's static
|
||||
/// <c>DataType</c>, so it is unit-testable in isolation.
|
||||
/// </summary>
|
||||
/// <param name="value">The value the client wrote (the SDK's pre-coercion payload).</param>
|
||||
/// <param name="node">The target node (expected to be a writable <see cref="BaseDataVariableState"/>).</param>
|
||||
/// <param name="refuseArrayOnScalar">True when the node's owning driver cannot write an array value to a
|
||||
/// scalar tag (Modbus / S7): an array value to a scalar node is then refused. False keeps the pre-#610
|
||||
/// behaviour for that case (dispatch; the driver — e.g. OpcUaClient's pass-through — decides).</param>
|
||||
/// <param name="coerced">On a <c>null</c> return, the value to store and dispatch: <paramref name="value"/>
|
||||
/// itself, or for a numeric array of another element type, the checked conversion to the node's element type.
|
||||
/// <c>null</c> when the write is refused.</param>
|
||||
/// <returns><c>null</c> to proceed; otherwise the veto <see cref="ServiceResult"/>
|
||||
/// (<c>BadTypeMismatch</c> for a null write or a confidently-detected built-in-type mismatch).</returns>
|
||||
internal static ServiceResult? EvaluateEquipmentWriteStructure(object? value, NodeState node)
|
||||
internal static ServiceResult? EvaluateEquipmentWriteStructure(
|
||||
object? value, NodeState node, bool refuseArrayOnScalar, out object? coerced)
|
||||
{
|
||||
coerced = null;
|
||||
var result = EvaluateEquipmentWriteStructureCore(value, node, refuseArrayOnScalar, ref coerced);
|
||||
if (result is not null) coerced = null;
|
||||
else coerced ??= value;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>The verdict half of <see cref="EvaluateEquipmentWriteStructure(object?, NodeState, bool, out object?)"/>
|
||||
/// for callers that only need the decision; the array-on-scalar refusal is applied as for a Modbus / S7 node.
|
||||
/// Production goes through the four-argument overload, which also returns the converted value.</summary>
|
||||
internal static ServiceResult? EvaluateEquipmentWriteStructure(object? value, NodeState node) =>
|
||||
EvaluateEquipmentWriteStructure(value, node, refuseArrayOnScalar: true, out _);
|
||||
|
||||
private static ServiceResult? EvaluateEquipmentWriteStructureCore(
|
||||
object? value, NodeState node, bool refuseArrayOnScalar, ref object? coerced)
|
||||
{
|
||||
// Minimum sensible check: a null payload is never a valid value write to a value variable.
|
||||
if (value is null)
|
||||
@@ -2073,10 +2113,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
if (expected is BuiltInType.Null or BuiltInType.Variant or BuiltInType.DataValue) return null; // unresolved / wildcard
|
||||
|
||||
// Gitea #610 review — the mirror case: an array value to a SCALAR node of a resolved DataType is refused
|
||||
// up front rather than optimistically stored, dispatched to a scalar encoder, refused and reverted. A
|
||||
// byte[] is exempt: on the wire it is a ByteString, which is a scalar (the type check below still decides
|
||||
// whether the node takes one). An unresolved-DataType node deferred above, as it always has.
|
||||
if (variable.ValueRank == ValueRanks.Scalar && value is Array and not byte[])
|
||||
// up front rather than optimistically stored, dispatched to a scalar encoder, refused and reverted — but
|
||||
// ONLY for a driver whose scalar encoder cannot take one (R6 MINOR-1). A browse-committed array attribute
|
||||
// lands as a SCALAR tag (the /raw commit never sets isArray) whose driver publishes an array into it, and
|
||||
// writing that array back worked before #610 on the pass-through drivers (OpcUaClient forwards the
|
||||
// Variant; Galaxy encodes it), so those keep dispatching and the driver decides. A byte[] is exempt: on
|
||||
// the wire it is a ByteString, which is a scalar (the type check below still decides whether the node
|
||||
// takes one). An unresolved-DataType node deferred above, as it always has.
|
||||
if (refuseArrayOnScalar && variable.ValueRank == ValueRanks.Scalar && value is Array and not byte[])
|
||||
{
|
||||
return new ServiceResult(StatusCodes.BadTypeMismatch, "a scalar node takes a scalar value");
|
||||
}
|
||||
@@ -2091,22 +2135,41 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// unclassifiable value ⇒ Unknown (Null).
|
||||
var actual = TypeInfo.Construct(value).BuiltInType;
|
||||
|
||||
// Gitea #610 review (MAJOR-1) — an ARRAY write is checked EXACTLY, element type against DataType. The SDK
|
||||
// runs its own TypeInfo.IsInstanceOfDataType check only when a node has NO OnWriteValue handler, and every
|
||||
// writable equipment node has one, so nothing else stands between a mistyped array and the driver: a
|
||||
// UInt32[] to a Galaxy Int32 array matched MxValueEncoder's int[] arm by CLR array covariance and wrote
|
||||
// uint.MaxValue as -1 with a Good result, and a Modbus/S7 node kept serving an int[] under an Int16
|
||||
// DataType. This restores the SDK's own rule for arrays — the element type must match, with the single
|
||||
// Byte-array/ByteString equivalence (a Byte[] decodes as byte[], which classifies as ByteString) — and
|
||||
// an unclassifiable element type (a Variant[] / object[]) is refused rather than deferred. Arrays were
|
||||
// never client-writable before #610, so no client relies on a looser rule.
|
||||
// Gitea #610 review (MAJOR-1, R6) — an ARRAY write's element type is checked against the DataType here,
|
||||
// because the SDK runs its own TypeInfo.IsInstanceOfDataType check only when a node has NO OnWriteValue
|
||||
// handler, and every writable equipment node has one: nothing else stands between a mistyped array and the
|
||||
// driver. Unchecked, a UInt32[] to a Galaxy Int32 array matched MxValueEncoder's int[] arm by CLR array
|
||||
// covariance and wrote 4,000,000,000 bit-reinterpreted with a Good result, and a Modbus/S7 node kept
|
||||
// serving an int[] under an Int16 DataType.
|
||||
// • A matching element type (or the Byte-array/ByteString equivalence — a Byte[] decodes as byte[], which
|
||||
// classifies as ByteString) proceeds unchanged.
|
||||
// • A NUMERIC array of another numeric element type is CONVERTED, checked, into the node's element type,
|
||||
// and the converted array is what the SDK stores and the driver receives. This is not optional: the
|
||||
// consumer #610 exists for, ScadaBridge, writes the CLR list it decoded from a List attribute, and its
|
||||
// element types are only Boolean/Int32/Float/Double/String/DateTime — an exact-match rule left every
|
||||
// Int16/UInt16/Byte/UInt32/Int64/UInt64 array node (Modbus's default Int16 included) and a Double list
|
||||
// to a Float array permanently unwritable from it. Any element out of the target's range, fractional
|
||||
// for an integer target, or NaN/±Infinity for an integer target refuses the WHOLE write — so the
|
||||
// UInt32 → Int32 reinterpretation stays closed (4,000,000,000 overflows; in-range values convert).
|
||||
// • Anything else — a non-numeric mismatch, or an unclassifiable element type (Variant[] / object[]) — is
|
||||
// refused rather than deferred.
|
||||
if (isArrayNode)
|
||||
{
|
||||
return actual == expected || (expected, actual) is (BuiltInType.Byte, BuiltInType.ByteString)
|
||||
? null
|
||||
: new ServiceResult(
|
||||
if (actual == expected || (expected, actual) is (BuiltInType.Byte, BuiltInType.ByteString)) return null;
|
||||
if (IsNumeric(expected) && IsNumeric(actual) && value is Array source)
|
||||
{
|
||||
if (TryConvertNumericArray(source, expected, out var converted))
|
||||
{
|
||||
coerced = converted;
|
||||
return null;
|
||||
}
|
||||
return new ServiceResult(
|
||||
StatusCodes.BadTypeMismatch,
|
||||
$"array element type {actual} does not match node data type {expected}");
|
||||
$"array element of type {actual} is not exactly representable as node data type {expected}");
|
||||
}
|
||||
return new ServiceResult(
|
||||
StatusCodes.BadTypeMismatch,
|
||||
$"array element type {actual} does not match node data type {expected}");
|
||||
}
|
||||
|
||||
if (actual == BuiltInType.Null) return null; // couldn't classify the payload ⇒ defer to the SDK
|
||||
@@ -2166,6 +2229,76 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
BuiltInType.Int32 or BuiltInType.UInt32 or BuiltInType.Int64 or BuiltInType.UInt64 or
|
||||
BuiltInType.Float or BuiltInType.Double;
|
||||
|
||||
/// <summary>
|
||||
/// #610 review R6 — CHECKED element-wise conversion of a numeric array to the node's numeric element type.
|
||||
/// Fails (nothing converted) when ANY element is out of the target's range, is fractional or NaN/±Infinity
|
||||
/// for an integer target, or is finite yet overflows to ±Infinity for a <see cref="BuiltInType.Float"/>
|
||||
/// target. Precision loss inside the target's range (a Double to a Float, a large Int64 to a Double) is
|
||||
/// accepted, as the scalar path's drivers accept it. Pure + static.
|
||||
/// </summary>
|
||||
/// <param name="source">The 1-D numeric array the client wrote.</param>
|
||||
/// <param name="target">The node's numeric element type.</param>
|
||||
/// <param name="converted">The converted array (element CLR type of <paramref name="target"/>), or null.</param>
|
||||
/// <returns>True when every element converted exactly enough to be written.</returns>
|
||||
internal static bool TryConvertNumericArray(Array source, BuiltInType target, out Array? converted)
|
||||
{
|
||||
converted = null;
|
||||
var elementType = target switch
|
||||
{
|
||||
BuiltInType.SByte => typeof(sbyte), BuiltInType.Byte => typeof(byte),
|
||||
BuiltInType.Int16 => typeof(short), BuiltInType.UInt16 => typeof(ushort),
|
||||
BuiltInType.Int32 => typeof(int), BuiltInType.UInt32 => typeof(uint),
|
||||
BuiltInType.Int64 => typeof(long), BuiltInType.UInt64 => typeof(ulong),
|
||||
BuiltInType.Float => typeof(float), BuiltInType.Double => typeof(double),
|
||||
_ => null,
|
||||
};
|
||||
if (elementType is null) return false;
|
||||
var result = Array.CreateInstance(elementType, source.Length);
|
||||
for (var i = 0; i < source.Length; i++)
|
||||
{
|
||||
if (!TryConvertNumericElement(source.GetValue(i), target, out var element)) return false;
|
||||
result.SetValue(element, i);
|
||||
}
|
||||
converted = result;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryConvertNumericElement(object? element, BuiltInType target, out object? result)
|
||||
{
|
||||
result = null;
|
||||
if (element is null) return false;
|
||||
var integerTarget = target is not (BuiltInType.Float or BuiltInType.Double);
|
||||
if (element is float or double)
|
||||
{
|
||||
var d = Convert.ToDouble(element, CultureInfo.InvariantCulture);
|
||||
// Convert.ToInt16(1.5) ROUNDS (banker's) rather than failing, so a fractional value is refused here.
|
||||
if (integerTarget && (!double.IsFinite(d) || Math.Truncate(d) != d)) return false;
|
||||
if (target == BuiltInType.Float && double.IsFinite(d) && float.IsInfinity((float)d)) return false;
|
||||
}
|
||||
try
|
||||
{
|
||||
result = target switch
|
||||
{
|
||||
BuiltInType.SByte => Convert.ToSByte(element, CultureInfo.InvariantCulture),
|
||||
BuiltInType.Byte => Convert.ToByte(element, CultureInfo.InvariantCulture),
|
||||
BuiltInType.Int16 => Convert.ToInt16(element, CultureInfo.InvariantCulture),
|
||||
BuiltInType.UInt16 => Convert.ToUInt16(element, CultureInfo.InvariantCulture),
|
||||
BuiltInType.Int32 => Convert.ToInt32(element, CultureInfo.InvariantCulture),
|
||||
BuiltInType.UInt32 => Convert.ToUInt32(element, CultureInfo.InvariantCulture),
|
||||
BuiltInType.Int64 => Convert.ToInt64(element, CultureInfo.InvariantCulture),
|
||||
BuiltInType.UInt64 => Convert.ToUInt64(element, CultureInfo.InvariantCulture),
|
||||
BuiltInType.Float => Convert.ToSingle(element, CultureInfo.InvariantCulture),
|
||||
BuiltInType.Double => Convert.ToDouble(element, CultureInfo.InvariantCulture),
|
||||
_ => null,
|
||||
};
|
||||
return result is not null;
|
||||
}
|
||||
catch (Exception ex) when (ex is OverflowException or InvalidCastException or FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The CLOSED set of expected built-in DataTypes the structural fail-fast is allowed to reject
|
||||
/// against — exactly the types <see cref="ResolveBuiltInDataType"/> can emit for a writable equipment node
|
||||
/// (the numeric families + Boolean / String / DateTime / ByteString). Any expected type OUTSIDE this set is
|
||||
@@ -2510,7 +2643,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// <paramref name="dataType"/> — rank + dimensions carry the array-ness.</param>
|
||||
/// <param name="arrayLength">Phase 4c: the declared length of the 1-D array when
|
||||
/// <paramref name="isArray"/> is true; ignored for scalars. Null ⇒ length 0.</param>
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
|
||||
ArgumentException.ThrowIfNullOrEmpty(displayName);
|
||||
@@ -2564,6 +2697,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// NodeId back to its Aveva/historian source. A historized UNS reference node passes the SAME
|
||||
// tagname as its backing raw node — each NodeId keys its own entry, both mapping to one tagname.
|
||||
if (historized) _historizedTagnames[key] = historianTagname!;
|
||||
if (refuseArrayOnScalar) _arrayOnScalarRefused[key] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2984,6 +3118,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
_variables.Clear();
|
||||
// Phase C: drop the NodeId→historian-tagname registrations alongside the variables they map.
|
||||
_historizedTagnames.Clear();
|
||||
_arrayOnScalarRefused.Clear();
|
||||
|
||||
foreach (var alarm in _alarmConditions.Values)
|
||||
{
|
||||
@@ -3059,6 +3194,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
PredefinedNodes?.Remove(variable.NodeId);
|
||||
// Drop the historized-tagname registration alongside the variable it maps (Phase C parity).
|
||||
_historizedTagnames.TryRemove(key, out _);
|
||||
_arrayOnScalarRefused.TryRemove(key, out _);
|
||||
e = BuildNodesRemovedModelChange(variableNodeId, realm);
|
||||
}
|
||||
ReportModelChangeOutsideLock(e, variableNodeId);
|
||||
@@ -3157,6 +3293,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
PredefinedNodes?.Remove(v.NodeId);
|
||||
}
|
||||
_historizedTagnames.TryRemove(id, out _);
|
||||
_arrayOnScalarRefused.TryRemove(id, out _);
|
||||
}
|
||||
|
||||
// Part 9 condition nodes (+ their native flags).
|
||||
|
||||
@@ -58,8 +58,8 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
|
||||
=> _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName, realm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
||||
=> _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength);
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
|
||||
=> _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength, refuseArrayOnScalar);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm)
|
||||
|
||||
@@ -81,7 +81,7 @@ public class AddressSpaceSinkBatchTests
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { }
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
|
||||
public void RebuildAddressSpace() { }
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
@@ -104,7 +104,7 @@ public class AddressSpaceSinkBatchTests
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { }
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
|
||||
public void RebuildAddressSpace() { }
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
|
||||
@@ -214,7 +214,7 @@ public class DeferredAddressSpaceSinkTests
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
|
||||
public void RebuildAddressSpace() => RebuildCalled = true;
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
|
||||
@@ -232,7 +232,7 @@ public class DeferredAddressSpaceSinkTests
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
|
||||
public void RebuildAddressSpace() { }
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
|
||||
@@ -156,4 +156,19 @@ public sealed class DriverArrayWriteSupportTests
|
||||
DriverArrayWriteSupport.Supports(driverType, "Int32", blob).ShouldBeFalse(driverType ?? "<null>");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>#610 review R6 MINOR-1 — only Modbus and S7 have a scalar encoder that cannot take an array value;
|
||||
/// every other driver (the pass-through OpcUaClient and Galaxy above all) keeps dispatching an array written to
|
||||
/// a scalar-shaped tag, as before #610.</summary>
|
||||
[Fact]
|
||||
public void Only_modbus_and_s7_refuse_an_array_value_on_a_scalar_tag()
|
||||
{
|
||||
DriverArrayWriteSupport.RefusesArrayValueOnScalarTag(DriverTypeNames.Modbus).ShouldBeTrue();
|
||||
DriverArrayWriteSupport.RefusesArrayValueOnScalarTag(DriverTypeNames.S7).ShouldBeTrue();
|
||||
foreach (var driverType in DriverTypeNames.All
|
||||
.Except([DriverTypeNames.Modbus, DriverTypeNames.S7]).Append("NoSuchDriver").Append(null))
|
||||
{
|
||||
DriverArrayWriteSupport.RefusesArrayValueOnScalarTag(driverType).ShouldBeFalse(driverType ?? "<null>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ public sealed class ArrayWriteWireTests
|
||||
private const string S7ArrayUns = "filling/line-1/station-1/Levels";
|
||||
private const string S7ScalarRaw = "Plant/S7a/plc1/Setpoint";
|
||||
private const string GalaxyArrayRaw = "Plant/Gx1/gx/Recipe";
|
||||
private const string UaScalarRaw = "Plant/Ua1/up/Vector";
|
||||
|
||||
private sealed class RecordingWriteGateway(bool succeed) : IOpcUaNodeWriteGateway
|
||||
{
|
||||
@@ -73,6 +74,20 @@ public sealed class ArrayWriteWireTests
|
||||
Name = "Gx1", DriverType = "GalaxyMxGateway", DriverConfig = "{}",
|
||||
};
|
||||
var gxDevice = new Device { DeviceId = "dev-3", DriverInstanceId = "drv-gx", Name = "gx", DeviceConfig = "{}" };
|
||||
// An upstream array variable committed from the /raw browser: the commit sets the DataType but never
|
||||
// isArray, so it materialises SCALAR while the driver publishes an array into it (R6 MINOR-1).
|
||||
var ua = new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "drv-ua", ClusterId = "c1", RawFolderId = "raw-plant",
|
||||
Name = "Ua1", DriverType = "OpcUaClient", DriverConfig = "{}",
|
||||
};
|
||||
var uaDevice = new Device { DeviceId = "dev-4", DriverInstanceId = "drv-ua", Name = "up", DeviceConfig = "{}" };
|
||||
var vector = new Tag
|
||||
{
|
||||
TagId = "t-vector", DeviceId = "dev-4", Name = "Vector", DataType = "Int32",
|
||||
AccessLevel = TagAccessLevel.ReadWrite,
|
||||
TagConfig = "{\"nodeId\":\"ns=2;s=Vector\"}",
|
||||
};
|
||||
var recipe = new Tag
|
||||
{
|
||||
TagId = "t-recipe", DeviceId = "dev-3", Name = "Recipe", DataType = "Int32",
|
||||
@@ -103,9 +118,9 @@ public sealed class ArrayWriteWireTests
|
||||
var reference = new UnsTagReference { UnsTagReferenceId = "ref-levels", EquipmentId = "eq-1", TagId = "t-levels" };
|
||||
|
||||
var composition = AddressSpaceComposer.Compose(
|
||||
[area], [line], [equip], [s7, ab, gx], Array.Empty<ScriptedAlarm>(),
|
||||
unsTagReferences: [reference], tags: [levels, counts, recipe, setpoint],
|
||||
rawFolders: [folder], devices: [plc1, plc2, gxDevice]);
|
||||
[area], [line], [equip], [s7, ab, gx, ua], Array.Empty<ScriptedAlarm>(),
|
||||
unsTagReferences: [reference], tags: [levels, counts, recipe, setpoint, vector],
|
||||
rawFolders: [folder], devices: [plc1, plc2, gxDevice, uaDevice]);
|
||||
|
||||
var applier = new AddressSpaceApplier(rig.Sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
applier.MaterialiseHierarchy(composition).ShouldBe(0);
|
||||
@@ -118,6 +133,7 @@ public sealed class ArrayWriteWireTests
|
||||
rig.Sink.WriteValue(AbArrayRaw, new[] { 5, 6, 7, 8 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
|
||||
rig.Sink.WriteValue(GalaxyArrayRaw, new[] { 11, 12 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
|
||||
rig.Sink.WriteValue(S7ScalarRaw, (short)5, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
|
||||
rig.Sink.WriteValue(UaScalarRaw, new[] { 1, 2 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
|
||||
return rig;
|
||||
}
|
||||
|
||||
@@ -247,9 +263,10 @@ public sealed class ArrayWriteWireTests
|
||||
|
||||
/// <summary>
|
||||
/// #610 review MAJOR-1 — the SDK runs no type check of its own on a node with an <c>OnWriteValue</c> handler,
|
||||
/// so the node manager must refuse a mistyped array before dispatch: an <c>Int32[]</c> to an S7 Int16 array
|
||||
/// (else the node serves an int[] under an Int16 DataType), and a <c>UInt32[]</c> to a Galaxy Int32 array
|
||||
/// (else MxValueEncoder's int[] arm takes it by covariance and writes 4,000,000,000 as -294,967,296, Good).
|
||||
/// so the node manager must refuse an array its node type cannot represent before dispatch: an <c>Int32[]</c>
|
||||
/// element outside Int16 to an S7 Int16 array, and a <c>UInt32[]</c> element above Int32.MaxValue to a Galaxy
|
||||
/// Int32 array (else MxValueEncoder's int[] arm takes it by covariance and writes 4,000,000,000 as
|
||||
/// -294,967,296, Good).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_mistyped_array_write_is_refused_inline_and_never_reaches_the_driver()
|
||||
@@ -264,7 +281,7 @@ public sealed class ArrayWriteWireTests
|
||||
|
||||
(await ReadAccessLevelAsync(session, galaxy, ct) & AccessLevels.CurrentWrite).ShouldBe(AccessLevels.CurrentWrite);
|
||||
|
||||
(await WriteAsync(session, s7, new[] { 1, 2, 3, 4 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch, "Int32[] to an Int16 array");
|
||||
(await WriteAsync(session, s7, new[] { 1, 2, 3, 70_000 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch, "an Int32 element outside Int16");
|
||||
(await WriteAsync(session, galaxy, new uint[] { 4_000_000_000, 1 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch, "UInt32[] to an Int32 array");
|
||||
(await ReadShortsAsync(session, s7, ct)).ShouldBe(new short[] { 1, 2, 3, 4 });
|
||||
((int[])(await session.ReadValueAsync(galaxy, ct)).Value).ShouldBe(new[] { 11, 12 });
|
||||
@@ -275,7 +292,53 @@ public sealed class ArrayWriteWireTests
|
||||
lock (gateway.Writes) gateway.Writes.ShouldHaveSingleItem().Value.ShouldBe(new[] { 21, 22 });
|
||||
}
|
||||
|
||||
/// <summary>#610 review MINOR-5 — an array value to a SCALAR node is refused before dispatch.</summary>
|
||||
/// <summary>
|
||||
/// #610 review R6 MAJOR-1 — ScadaBridge writes the CLR list a List attribute decodes to (here Int32, the only
|
||||
/// integer element type it has) to an Int16 array node. The write is converted, checked, to the node's
|
||||
/// element type: the client sees Good, the node serves <c>short[]</c>, and the driver receives <c>short[]</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_numeric_array_of_another_element_type_is_converted_to_the_node_type()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await StartAsync("array-convert", ct);
|
||||
var gateway = new RecordingWriteGateway(succeed: true);
|
||||
rig.Nm.NodeWriteGateway = gateway;
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var s7 = Raw(session, S7ArrayRaw);
|
||||
|
||||
(await WriteAsync(session, s7, new[] { 10, -20, 30, 32_767 }, ct)).ShouldBe((StatusCode)StatusCodes.Good);
|
||||
(await ReadShortsAsync(session, s7, ct)).ShouldBe(new short[] { 10, -20, 30, 32_767 });
|
||||
lock (gateway.Writes)
|
||||
{
|
||||
var dispatched = gateway.Writes.ShouldHaveSingleItem().Value.ShouldBeOfType<short[]>();
|
||||
dispatched.ShouldBe(new short[] { 10, -20, 30, 32_767 });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #610 review R6 MINOR-1 — a pass-through driver's scalar-shaped node (an upstream array variable committed
|
||||
/// from <c>/raw</c> without <c>isArray</c>) still dispatches an array write, as it did before #610: the
|
||||
/// upstream server arbitrates the shape. The S7 scalar's refusal is the other side of the same rule.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_array_value_to_a_pass_through_scalar_node_is_still_dispatched()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await StartAsync("array-to-ua-scalar", ct);
|
||||
var gateway = new RecordingWriteGateway(succeed: true);
|
||||
rig.Nm.NodeWriteGateway = gateway;
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
|
||||
(await WriteAsync(session, Raw(session, UaScalarRaw), new[] { 7, 8, 9 }, ct)).ShouldBe((StatusCode)StatusCodes.Good);
|
||||
lock (gateway.Writes) gateway.Writes.ShouldHaveSingleItem().Value.ShouldBe(new[] { 7, 8, 9 });
|
||||
|
||||
(await WriteAsync(session, Raw(session, S7ScalarRaw), new short[] { 9 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
|
||||
gateway.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>#610 review MINOR-5 — an array value to a SCALAR node of a driver whose scalar encoder cannot take
|
||||
/// one (S7) is refused before dispatch.</summary>
|
||||
[Fact]
|
||||
public async Task An_array_value_to_a_scalar_node_is_refused_inline()
|
||||
{
|
||||
|
||||
+1
-1
@@ -175,7 +175,7 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
|
||||
if (ThrowOnEnsureFolder) throw new InvalidOperationException("simulated EnsureFolder fault");
|
||||
}
|
||||
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
|
||||
{
|
||||
if (ThrowOnEnsureVariable) throw new InvalidOperationException("simulated EnsureVariable fault");
|
||||
}
|
||||
|
||||
+1
-1
@@ -350,7 +350,7 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
|
||||
/// <param name="dataType">The OPC UA built-in type name.</param>
|
||||
/// <param name="writable">Whether the node is created read/write.</param>
|
||||
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
|
||||
/// <summary>Rebuilds the address space (stub implementation for testing).</summary>
|
||||
public void RebuildAddressSpace() { }
|
||||
/// <summary>Announces a NodeAdded model-change (stub implementation for testing).</summary>
|
||||
|
||||
@@ -486,7 +486,7 @@ public sealed class AddressSpaceApplierRawUnsTests
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
|
||||
=> Folders.Add((folderNodeId, parentNodeId, realm));
|
||||
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
|
||||
=> Variables.Add((variableNodeId, parentFolderNodeId, writable, historianTagname, realm, isArray, arrayLength));
|
||||
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
|
||||
|
||||
@@ -2033,7 +2033,7 @@ public sealed class AddressSpaceApplierTests
|
||||
/// <param name="dataType">The OPC UA built-in type name.</param>
|
||||
/// <param name="writable">Whether the node is created read/write.</param>
|
||||
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
|
||||
{
|
||||
VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
|
||||
HistorianQueue.Enqueue((variableNodeId, historianTagname));
|
||||
@@ -2077,7 +2077,7 @@ public sealed class AddressSpaceApplierTests
|
||||
/// <summary>No-op folder creation call.</summary>
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
/// <summary>No-op variable creation call.</summary>
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
|
||||
/// <summary>Records a rebuild address space call.</summary>
|
||||
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
||||
/// <summary>No-op NodeAdded model-change announcement.</summary>
|
||||
@@ -2128,7 +2128,7 @@ public sealed class AddressSpaceApplierTests
|
||||
/// <param name="dataType">The OPC UA built-in type name.</param>
|
||||
/// <param name="writable">Whether the node is created read/write.</param>
|
||||
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
|
||||
/// <summary>No-op rebuild address space call.</summary>
|
||||
public void RebuildAddressSpace() { }
|
||||
/// <summary>No-op NodeAdded model-change announcement.</summary>
|
||||
|
||||
+8
@@ -306,6 +306,14 @@ public sealed class AddressSpaceComposerDualNamespaceTests
|
||||
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();
|
||||
|
||||
// #610 review R6 MINOR-1: the array-on-scalar refusal follows the owning DRIVER (S7 yes, AbCip no — AbCip
|
||||
// keeps pre-#610 dispatch), is not gated on the tag's shape, and reaches the UNS mirror.
|
||||
c.RawTags.Single(t => t.Name == "Level").ScalarRefusesArrayValue.ShouldBeTrue();
|
||||
c.RawTags.Single(t => t.Name == "Levels").ScalarRefusesArrayValue.ShouldBeTrue();
|
||||
c.RawTags.Single(t => t.Name == "Counts").ScalarRefusesArrayValue.ShouldBeFalse();
|
||||
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r1").BackingScalarRefusesArrayValue.ShouldBeTrue();
|
||||
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r3").BackingScalarRefusesArrayValue.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>#610 review MINOR-1 — the composer hands the table the owning driver's <c>DriverConfig</c> AND the
|
||||
|
||||
@@ -84,6 +84,22 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
call.HistorianTagname.ShouldBe("MyTag.PV");
|
||||
}
|
||||
|
||||
/// <summary>#610 review R6 — the array-on-scalar refusal flag reaches the inner sink. Actors inject THIS wrapper,
|
||||
/// so a dropped flag would leave every Modbus/S7 scalar node dispatching array writes (the reflection guard
|
||||
/// only proves the call arrives, not its arguments).</summary>
|
||||
[Fact]
|
||||
public void EnsureVariable_forwards_refuseArrayOnScalar_to_inner_sink()
|
||||
{
|
||||
var deferred = new DeferredAddressSpaceSink();
|
||||
var inner = new RecordingSink();
|
||||
deferred.SetSink(inner);
|
||||
|
||||
deferred.EnsureVariable("v-1", null, "MyVar", "Int16", writable: true, AddressSpaceRealm.Raw, refuseArrayOnScalar: true);
|
||||
deferred.EnsureVariable("v-2", null, "MyVar", "Int16", writable: true, AddressSpaceRealm.Raw);
|
||||
|
||||
inner.RefuseArrayOnScalarCalls.ShouldBe(new[] { ("v-1", true), ("v-2", false) });
|
||||
}
|
||||
|
||||
/// <summary>F10b regression: the deferred wrapper MUST forward the surgical capability to a
|
||||
/// surgical inner sink — otherwise <c>AddressSpaceApplier</c> (which injects THIS wrapper on every
|
||||
/// driver-role host, not the inner <c>SdkAddressSpaceSink</c>) never sees the capability and the
|
||||
@@ -226,6 +242,10 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
public ConcurrentQueue<(string NodeId, string? HistorianTagname)> HistorianQueue { get; } = new();
|
||||
/// <summary>Gets the list of (NodeId, HistorianTagname) pairs captured per EnsureVariable call.</summary>
|
||||
public List<(string NodeId, string? HistorianTagname)> HistorianCalls => HistorianQueue.ToList();
|
||||
/// <summary>Gets the (NodeId, refuseArrayOnScalar) pairs captured per EnsureVariable call.</summary>
|
||||
public ConcurrentQueue<(string NodeId, bool Refuse)> RefuseArrayOnScalarQueue { get; } = new();
|
||||
/// <summary>Gets the list of (NodeId, refuseArrayOnScalar) pairs captured per EnsureVariable call.</summary>
|
||||
public List<(string NodeId, bool Refuse)> RefuseArrayOnScalarCalls => RefuseArrayOnScalarQueue.ToList();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
@@ -241,10 +261,11 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
=> CallQueue.Enqueue($"EF:{folderNodeId}");
|
||||
/// <inheritdoc />
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
|
||||
{
|
||||
CallQueue.Enqueue($"EV:{variableNodeId}");
|
||||
HistorianQueue.Enqueue((variableNodeId, historianTagname));
|
||||
RefuseArrayOnScalarQueue.Enqueue((variableNodeId, refuseArrayOnScalar));
|
||||
}
|
||||
/// <inheritdoc />
|
||||
public void RebuildAddressSpace() => CallQueue.Enqueue("RB");
|
||||
@@ -297,7 +318,7 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
/// <inheritdoc />
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
/// <inheritdoc />
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
|
||||
/// <inheritdoc />
|
||||
public void RebuildAddressSpace() { }
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -214,9 +214,10 @@ public sealed class EquipmentWriteGateTests
|
||||
}
|
||||
|
||||
/// <summary>#610 review MAJOR-1 — the SDK skips its own type check when <c>OnWriteValue</c> is set, so an array
|
||||
/// write's ELEMENT type must match the node's DataType exactly here: no numeric widening for arrays (a
|
||||
/// <c>uint[]</c> to an Int32 node would reach MxValueEncoder's <c>int[]</c> arm by covariance and reinterpret
|
||||
/// its bits). The matching element type is the positive control.</summary>
|
||||
/// write's ELEMENT type is checked here: a non-numeric mismatch, an unclassifiable element type, or a numeric
|
||||
/// element the node's type cannot represent exactly (a <c>uint[]</c> element above Int32.MaxValue would reach
|
||||
/// MxValueEncoder's <c>int[]</c> arm by covariance and reinterpret its bits) is refused. The matching element
|
||||
/// type is the positive control.</summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(MistypedArrays))]
|
||||
public void Structure_array_node_requires_the_exact_element_type(NodeId dataType, Array matching, Array mistyped)
|
||||
@@ -229,28 +230,103 @@ public sealed class EquipmentWriteGateTests
|
||||
|
||||
public static TheoryData<NodeId, Array, Array> MistypedArrays() => new()
|
||||
{
|
||||
{ DataTypeIds.Int16, new short[] { 1, 2 }, new[] { 1, 2 } }, // Int32[] → Int16
|
||||
{ DataTypeIds.Int16, new short[] { 1, 2 }, new[] { 1, 70_000 } }, // Int32[] → Int16, out of range
|
||||
{ DataTypeIds.Int32, new[] { 1, 2 }, new uint[] { 4_000_000_000, 2 } }, // UInt32[] → Int32 (Galaxy)
|
||||
{ DataTypeIds.Int64, new long[] { 1, 2 }, new ulong[] { 1, 2 } }, // UInt64[] → Int64 (Galaxy)
|
||||
{ DataTypeIds.Float, new[] { 1f, 2f }, new[] { 1d, 2d } }, // Double[] → Float
|
||||
{ DataTypeIds.Int64, new long[] { 1, 2 }, new ulong[] { ulong.MaxValue, 2 } }, // UInt64[] → Int64 (Galaxy)
|
||||
{ DataTypeIds.Float, new[] { 1f, 2f }, new[] { 1d, 1e300 } }, // Double[] → Float, overflows
|
||||
{ DataTypeIds.Int16, new short[] { 1, 2 }, new[] { 1.5d, 2d } }, // fractional → integer
|
||||
{ DataTypeIds.Int32, new[] { 1, 2 }, new[] { double.NaN, 2d } }, // NaN → integer
|
||||
{ DataTypeIds.Int64, new long[] { 1, 2 }, new[] { double.PositiveInfinity, 2d } },// ∞ → integer
|
||||
{ DataTypeIds.UInt16, new ushort[] { 1, 2 }, new[] { -1, 2 } }, // negative → unsigned
|
||||
{ DataTypeIds.Int32, new[] { 1, 2 }, new[] { true, false } }, // Boolean[] → Int32
|
||||
{ DataTypeIds.String, new[] { "a", "b" }, new[] { 1, 2 } }, // Int32[] → String
|
||||
{ DataTypeIds.Boolean, new[] { true, false }, new[] { 1, 0 } }, // Int32[] → Boolean
|
||||
{ DataTypeIds.Int16, new short[] { 1, 2 }, new object[] { (short)1, (short)2 } }, // unclassifiable ⇒ refused
|
||||
};
|
||||
|
||||
/// <summary>#610 review R6 MAJOR-1 — ScadaBridge writes the CLR list it decoded from a List attribute, whose
|
||||
/// element type can only be Boolean/Int32/Float/Double/String/DateTime. A numeric array of another element type
|
||||
/// is CONVERTED (checked) to the node's element type, and the converted array — not the client's — is what the
|
||||
/// production overload hands back to be stored and dispatched.</summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(ConvertibleArrays))]
|
||||
public void Structure_numeric_array_of_another_element_type_is_converted_to_the_node_type(NodeId dataType, Array written, Array expected)
|
||||
{
|
||||
var node = ArrayNode(dataType, length: (uint)written.Length);
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(written, node, refuseArrayOnScalar: true, out var coerced)
|
||||
.ShouldBeNull();
|
||||
coerced.ShouldNotBeNull().GetType().ShouldBe(expected.GetType());
|
||||
coerced.ShouldBe(expected);
|
||||
}
|
||||
|
||||
public static TheoryData<NodeId, Array, Array> ConvertibleArrays() => new()
|
||||
{
|
||||
{ DataTypeIds.Int16, new[] { 1, -2, 32_767 }, new short[] { 1, -2, 32_767 } }, // Int32 list → Modbus default
|
||||
{ DataTypeIds.Float, new[] { 1.5d, -2.25d }, new[] { 1.5f, -2.25f } }, // Double list → Float array
|
||||
{ DataTypeIds.Int64, new[] { 1, -2 }, new long[] { 1, -2 } }, // Int32 list → Galaxy Int64
|
||||
{ DataTypeIds.UInt16, new[] { 0, 65_535 }, new ushort[] { 0, 65_535 } },
|
||||
{ DataTypeIds.UInt32, new[] { 0, int.MaxValue }, new uint[] { 0, int.MaxValue } },
|
||||
{ DataTypeIds.Byte, new[] { 0, 255 }, new byte[] { 0, 255 } },
|
||||
{ DataTypeIds.Int16, new[] { 2.0d, -3.0d }, new short[] { 2, -3 } }, // integral doubles convert
|
||||
{ DataTypeIds.Int32, new uint[] { 5, 2_147_483_647 }, new[] { 5, int.MaxValue } }, // in-range UInt32 → Int32
|
||||
{ DataTypeIds.Double, new[] { 1f, 2.5f }, new[] { 1d, 2.5d } },
|
||||
};
|
||||
|
||||
/// <summary>An exact element-type match is passed through unconverted (the same instance).</summary>
|
||||
[Fact]
|
||||
public void Structure_matching_array_is_passed_through_unchanged()
|
||||
{
|
||||
var written = new short[] { 1, 2 };
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(written, ArrayNode(DataTypeIds.Int16, 2), refuseArrayOnScalar: true, out var coerced)
|
||||
.ShouldBeNull();
|
||||
coerced.ShouldBeSameAs(written);
|
||||
}
|
||||
|
||||
/// <summary>A refused write hands back no value to store.</summary>
|
||||
[Fact]
|
||||
public void Structure_refused_array_returns_no_coerced_value()
|
||||
{
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new[] { 70_000 }, ArrayNode(DataTypeIds.Int16, 1), refuseArrayOnScalar: true, out var coerced)
|
||||
.ShouldNotBeNull();
|
||||
coerced.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>A Byte array decodes as <c>byte[]</c>, which classifies as ByteString — the one equivalence the SDK
|
||||
/// itself honours for array nodes.</summary>
|
||||
/// itself honours for array nodes. An SByte array is numeric, so it converts when every element fits (R6) and is
|
||||
/// refused when one does not.</summary>
|
||||
[Fact]
|
||||
public void Structure_byte_array_node_takes_a_byte_array()
|
||||
{
|
||||
var node = ArrayNode(DataTypeIds.Byte, length: 2);
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new byte[] { 1, 2 }, node).ShouldBeNull();
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new sbyte[] { 1, 2 }, node)!.StatusCode
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new sbyte[] { 1, 2 }, node, refuseArrayOnScalar: true, out var coerced)
|
||||
.ShouldBeNull();
|
||||
coerced.ShouldBe(new byte[] { 1, 2 });
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new sbyte[] { -1, 2 }, node)!.StatusCode
|
||||
.ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
|
||||
}
|
||||
|
||||
/// <summary>#610 review MINOR-5 — the mirror case: an array value to a scalar node is refused up front. A
|
||||
/// byte[] (a ByteString on the wire) is a scalar and still reaches the type check; an unresolved-DataType node
|
||||
/// defers as before.</summary>
|
||||
/// <summary>#610 review R6 MINOR-1 — a scalar node whose driver passes the value through (OpcUaClient, Galaxy:
|
||||
/// a browse-committed array attribute committed as a scalar tag) keeps the pre-#610 behaviour: an array value is
|
||||
/// dispatched and the driver decides. The Modbus/S7 refusal is the control.</summary>
|
||||
[Fact]
|
||||
public void Structure_scalar_node_of_a_pass_through_driver_accepts_an_array_value()
|
||||
{
|
||||
var node = ValueNode(DataTypeIds.Int32);
|
||||
var written = new[] { 1, 2, 3 };
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(written, node, refuseArrayOnScalar: false, out var coerced)
|
||||
.ShouldBeNull();
|
||||
coerced.ShouldBeSameAs(written);
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(written, node, refuseArrayOnScalar: true, out _)!.StatusCode
|
||||
.ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
|
||||
// The element-type check still applies on the pass-through path: a String[] to an Int32 node is refused.
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new[] { "a" }, node, refuseArrayOnScalar: false, out _)!.StatusCode
|
||||
.ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
|
||||
}
|
||||
|
||||
/// <summary>#610 review MINOR-5 — the mirror case: an array value to a scalar node of a driver that cannot take
|
||||
/// one (Modbus / S7 — the two-argument overload's assumption) is refused up front. A byte[] (a ByteString on the
|
||||
/// wire) is a scalar and still reaches the type check; an unresolved-DataType node defers as before.</summary>
|
||||
[Fact]
|
||||
public void Structure_scalar_node_refuses_an_array_value()
|
||||
{
|
||||
|
||||
+1
-1
@@ -220,7 +220,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
|
||||
/// <param name="dataType">The OPC UA built-in type name.</param>
|
||||
/// <param name="writable">Whether the node is created read/write.</param>
|
||||
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
|
||||
/// <summary>Rebuilds address space (recorded via span).</summary>
|
||||
public void RebuildAddressSpace() { /* recorded via span */ }
|
||||
/// <summary>Announces a NodeAdded model-change (stub implementation).</summary>
|
||||
|
||||
+2
-2
@@ -138,7 +138,7 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
|
||||
public void RebuildAddressSpace() => throw new InvalidOperationException("simulated rebuild fault");
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
@@ -153,7 +153,7 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
|
||||
public void RebuildAddressSpace() { }
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
|
||||
@@ -172,7 +172,7 @@ public sealed class OpcUaPublishActorBatchTests : RuntimeActorTestBase
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
|
||||
public void RebuildAddressSpace() { }
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
|
||||
@@ -1044,7 +1044,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
/// <param name="dataType">The OPC UA built-in type name.</param>
|
||||
/// <param name="writable">Whether the node is created read/write.</param>
|
||||
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
|
||||
{
|
||||
if (string.Equals(FailEnsureVariableFor, variableNodeId, StringComparison.Ordinal))
|
||||
throw new InvalidOperationException($"simulated EnsureVariable fault for {variableNodeId}");
|
||||
|
||||
+1
-1
@@ -146,7 +146,7 @@ public sealed class OpcUaPublishActorStatusBatchTests : RuntimeActorTestBase
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
|
||||
public void RebuildAddressSpace() { }
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
|
||||
@@ -772,7 +772,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
||||
/// <param name="dataType">The OPC UA built-in type name.</param>
|
||||
/// <param name="writable">Whether the node is created read/write.</param>
|
||||
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) =>
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) =>
|
||||
VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
|
||||
|
||||
/// <summary>Records a rebuild call.</summary>
|
||||
|
||||
Reference in New Issue
Block a user