Merge review R4 fixes (#610): array writes require an exact element-type match (BadTypeMismatch, nothing sent); Galaxy encoder refuses uint[]/ulong[]; Modbus array writability bounded by maxRegistersPerWrite/coil cap; arrayLength >= 1 required; Galaxy arrays without a declared length stay read-only; arrays to scalar nodes refused
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+32
-5
@@ -323,12 +323,17 @@ carried on the plan records (`RawTagPlan.ArrayWritable`, mirrored onto every UNS
|
|||||||
|
|
||||||
| Driver | Array writes | Element types |
|
| Driver | Array writes | Element types |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Modbus** | Yes | `region: Coils` (FC15, any element); `region: HoldingRegisters` with `Int16`/`UInt16`/`Bcd16`/`Int32`/`UInt32`/`Bcd32`/`Float32`/`Int64`/`UInt64`/`Float64` (FC16). Not BitInRegister / String / Bool-in-register; input regions are read-only |
|
| **Modbus** | Yes | `region: Coils` (FC15, any element, ≤ 1968 coils); `region: HoldingRegisters` with `Int16`/`UInt16`/`Bcd16`/`Int32`/`UInt32`/`Bcd32`/`Float32`/`Int64`/`UInt64`/`Float64` (FC16) whose `arrayLength × registers-per-element` fits `maxRegistersPerWrite` (default 123; the smallest value set on the driver or the tag's device wins) — the driver refuses rather than splits an over-cap FC16, so e.g. a 62-element `Int32` array (124 registers) stays read-only. Not BitInRegister / String / Bool-in-register; input regions are read-only |
|
||||||
| **S7** | Yes | `Byte`/`Int16`/`UInt16`/`Int32`/`UInt32`/`Float32` (one contiguous block). Not `Bool` (would clobber neighbouring bits) nor the wide / String / DateTime types |
|
| **S7** | Yes | `Byte`/`Int16`/`UInt16`/`Int32`/`UInt32`/`Float32` (one contiguous block). Not `Bool` (would clobber neighbouring bits) nor the wide / String / DateTime types |
|
||||||
| **OpcUaClient** | Yes | All — forwarded upstream unchanged; the upstream server decides |
|
| **OpcUaClient** | Yes | All — forwarded upstream unchanged; the upstream server decides (shape included) |
|
||||||
| **Galaxy** | Yes | OPC UA `Boolean`/`Int32`/`Int64`/`Float`/`Double`/`String`/`DateTime` (the `MxValueEncoder` array variants) |
|
| **Galaxy** | Yes | OPC UA `Boolean`/`Int32`/`Int64`/`Float`/`Double`/`String`/`DateTime` (the `MxValueEncoder` array variants) — **only with a declared `arrayLength`**: an undeclared length materialises an unbounded node, and a short whole-array write would *resize* the Galaxy attribute (mxaccessgw contract), so such a node stays read-only |
|
||||||
| AB CIP, AB Legacy, TwinCAT, FOCAS, MQTT, MTConnect, Sql, Calculation | **No** — scalar-only write encoders; array nodes stay read-only and a client write is `BadNotWritable` | — |
|
| AB CIP, AB Legacy, TwinCAT, FOCAS, MQTT, MTConnect, Sql, Calculation | **No** — scalar-only write encoders; array nodes stay read-only and a client write is `BadNotWritable` | — |
|
||||||
|
|
||||||
|
Modbus, S7 and Galaxy additionally need a **declared length** — `isArray: true` *and* an integer
|
||||||
|
`arrayLength >= 1`. The Modbus and S7 tag factories read anything else as a **scalar** tag (whose
|
||||||
|
scalar encoder cannot take an array), even though the composer still materialises an array node;
|
||||||
|
that node stays read-only.
|
||||||
|
|
||||||
Each "Yes" row is pinned against the driver's real encoder by a parity test in that driver's
|
Each "Yes" row is pinned against the driver's real encoder by a parity test in that driver's
|
||||||
test project (`*ArrayWriteSupportParityTests`), so a driver gaining or losing an array encoder
|
test project (`*ArrayWriteSupportParityTests`), so a driver gaining or losing an array encoder
|
||||||
fails a test until the table follows.
|
fails a test until the table follows.
|
||||||
@@ -336,8 +341,20 @@ fails a test until the table follows.
|
|||||||
What a client sees on a writable array node:
|
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
|
- **Whole-array writes only.** The value must be a 1-D array of exactly `arrayLength` elements
|
||||||
(an unbounded `arrayLength`/`ArrayDimensions = [0]` takes any length); anything else is
|
(an unbounded `arrayLength`/`ArrayDimensions = [0]` takes any length — reachable only on
|
||||||
`BadTypeMismatch`, refused inline before dispatch. A partial **IndexRange** write is
|
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.
|
||||||
|
- A partial **IndexRange** write is
|
||||||
`BadWriteNotSupported` — every array-capable driver writes the whole block, and the server
|
`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.
|
does not hold the device array authoritatively enough to read-modify-write it.
|
||||||
- The same realm-qualified `WriteOperate` / ACL gate as a scalar applies to both NodeIds, and a
|
- The same realm-qualified `WriteOperate` / ACL gate as a scalar applies to both NodeIds, and a
|
||||||
@@ -347,6 +364,16 @@ What a client sees on a writable array node:
|
|||||||
array tag) is a **surgical** in-place writability update, not a rebuild: `ArrayWritable` is on
|
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`.
|
||||||
|
|
||||||
|
> ⚠️ **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
|
||||||
|
> upgrading, every array tag **authored `ReadWrite`** on Modbus, S7, OpcUaClient or Galaxy whose
|
||||||
|
> shape the table above accepts is materialised **writable** — no deployment needed, since the
|
||||||
|
> capability is derived at compose time (a `FetchAndCache` node re-composes from its cached
|
||||||
|
> artifact). Any client holding `WriteOperate` (or, with `Security:Acl:Enabled`, a `WriteOperate`
|
||||||
|
> grant covering the tag) can then write whole PLC arrays. Before upgrading, audit array tags
|
||||||
|
> authored `ReadWrite` that were never meant to be written and set them `Read`; array tags on the
|
||||||
|
> other drivers are unaffected.
|
||||||
|
|
||||||
### Out of scope (named deferrals)
|
### Out of scope (named deferrals)
|
||||||
|
|
||||||
- **Multi-dimensional arrays** (`ValueRank > 1`) — **out of scope by decision** (#610 item 3),
|
- **Multi-dimensional arrays** (`ValueRank > 1`) — **out of scope by decision** (#610 item 3),
|
||||||
|
|||||||
@@ -116,3 +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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -137,8 +137,13 @@ numeric, String, and BitInRegister array reads.
|
|||||||
**Live-verify** — Mac-verifiable against the simulator at `10.100.0.35:5020` (no fixture
|
**Live-verify** — Mac-verifiable against the simulator at `10.100.0.35:5020` (no fixture
|
||||||
bring-up required; the sim is always-on on the shared Docker host).
|
bring-up required; the sim is always-on on the shared Docker host).
|
||||||
|
|
||||||
**Deferrals** — array *writes* (FC16 multi-register write for an array value) are a named
|
**Array writes** (#610) — a whole-array write goes out as one FC15 (coils) or one FC16 (numeric
|
||||||
follow-up; multi-dimensional arrays (`arrayLength` is always 1-D); per-element historization.
|
holding registers); the driver refuses rather than splits an FC16 longer than `maxRegistersPerWrite`
|
||||||
|
(default 123). The server mirrors that: an array tag is client-writable only when
|
||||||
|
`arrayLength × registers-per-element` fits the cap (and a coil array ≤ 1968), else its node stays
|
||||||
|
read-only — see [Uns.md §Array writes](../Uns.md#array-writes-610), including the upgrade note.
|
||||||
|
|
||||||
|
**Deferrals** — multi-dimensional arrays (`arrayLength` is always 1-D); per-element historization.
|
||||||
|
|
||||||
See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix
|
See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix
|
||||||
and the UI authoring flow.
|
and the UI authoring flow.
|
||||||
|
|||||||
@@ -20,28 +20,46 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|||||||
/// until this table follows.
|
/// until this table follows.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item><b>Modbus</b> — <c>region: Coils</c> (FC15, any element) and <c>region: HoldingRegisters</c>
|
/// <item><b>Modbus</b> — <c>region: Coils</c> (FC15, any element, at most 1968 coils) and
|
||||||
/// with a fixed-width numeric <c>dataType</c> (FC16). Bit-in-register, String and Bool-in-register
|
/// <c>region: HoldingRegisters</c> with a fixed-width numeric <c>dataType</c> (FC16) whose
|
||||||
/// arrays have no array encoder; input regions are read-only.</item>
|
/// <c>arrayLength × registers-per-element</c> fits the driver's <c>maxRegistersPerWrite</c> (default
|
||||||
|
/// 123 — the driver refuses rather than chunks a longer FC16). Bit-in-register, String and
|
||||||
|
/// Bool-in-register arrays have no array encoder; input regions are read-only.</item>
|
||||||
/// <item><b>S7</b> — <c>dataType</c> Byte / Int16 / UInt16 / Int32 / UInt32 / Float32 (one contiguous
|
/// <item><b>S7</b> — <c>dataType</c> Byte / Int16 / UInt16 / Int32 / UInt32 / Float32 (one contiguous
|
||||||
/// block, <c>EncodeArrayBlock</c>). Bool arrays would clobber neighbouring bits; the wide / String /
|
/// block, <c>EncodeArrayBlock</c>). Bool arrays would clobber neighbouring bits; the wide / String /
|
||||||
/// DateTime element types have no array encoder.</item>
|
/// DateTime element types have no array encoder.</item>
|
||||||
/// <item><b>OpcUaClient</b> — every element type: the value is forwarded to the upstream server as a
|
/// <item><b>OpcUaClient</b> — every element type: the value is forwarded to the upstream server as a
|
||||||
/// <c>Variant</c> unchanged, and the upstream server is the arbiter.</item>
|
/// <c>Variant</c> unchanged, and the upstream server is the arbiter (of shape, too).</item>
|
||||||
/// <item><b>Galaxy</b> — the array variants <c>MxValueEncoder</c> encodes, keyed by the OPC UA
|
/// <item><b>Galaxy</b> — the array variants <c>MxValueEncoder</c> encodes, keyed by the OPC UA
|
||||||
/// <c>DataType</c> the node is created with: Boolean / Int32 / Int64 / Float / Double / String /
|
/// <c>DataType</c> the node is created with: Boolean / Int32 / Int64 / Float / Double / String /
|
||||||
/// DateTime.</item>
|
/// DateTime — and only with a declared <c>arrayLength</c>: an undeclared length materialises an
|
||||||
|
/// unbounded node, and a short whole-array write to it would RESIZE the Galaxy attribute (the
|
||||||
|
/// mxaccessgw <c>gateway.md</c> contract), which is never what a client meant.</item>
|
||||||
/// <item>Everything else (AbCip, AbLegacy, TwinCAT, FOCAS, MQTT, MTConnect, Sql, Calculation) — scalar
|
/// <item>Everything else (AbCip, AbLegacy, TwinCAT, FOCAS, MQTT, MTConnect, Sql, Calculation) — scalar
|
||||||
/// write encoders only; array nodes stay read-only.</item>
|
/// write encoders only; array nodes stay read-only.</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
|
/// <para>Modbus, S7 and Galaxy require a declared length — <c>isArray: true</c> AND an integer
|
||||||
|
/// <c>arrayLength >= 1</c>. The Modbus and S7 tag factories treat anything else as a SCALAR tag (whose
|
||||||
|
/// scalar encoder cannot take an array value), though the composer still materialises an array node.</para>
|
||||||
/// <para>Multi-dimensional arrays (<c>ValueRank > 1</c>) are out of scope altogether — every array node
|
/// <para>Multi-dimensional arrays (<c>ValueRank > 1</c>) are out of scope altogether — every array node
|
||||||
/// is materialised 1-D (<c>ValueRank = OneDimension</c>), so nothing here considers more than one rank.</para>
|
/// is materialised 1-D (<c>ValueRank = OneDimension</c>), so nothing here considers more than one rank.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class DriverArrayWriteSupport
|
public static class DriverArrayWriteSupport
|
||||||
{
|
{
|
||||||
private static readonly HashSet<string> ModbusRegisterArrayTypes = new(StringComparer.OrdinalIgnoreCase)
|
/// <summary>The Modbus driver's FC16 write cap when <c>maxRegistersPerWrite</c> is absent or <c>0</c>
|
||||||
|
/// (<c>ModbusDriver</c> resolves it the same way).</summary>
|
||||||
|
public const int ModbusDefaultMaxRegistersPerWrite = 123;
|
||||||
|
|
||||||
|
/// <summary>Modbus FC15's protocol ceiling on coils per request (0x07B0). The driver sends one FC15 for the
|
||||||
|
/// whole array without chunking, so a longer coil array is refused by every conformant device.</summary>
|
||||||
|
public const int ModbusMaxCoilsPerWrite = 1968;
|
||||||
|
|
||||||
|
// Element type → registers per element (ModbusDriver.RegisterCount).
|
||||||
|
private static readonly Dictionary<string, int> ModbusRegisterArrayTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
"Int16", "UInt16", "Bcd16", "Int32", "UInt32", "Bcd32", "Float32", "Int64", "UInt64", "Float64",
|
["Int16"] = 1, ["UInt16"] = 1, ["Bcd16"] = 1,
|
||||||
|
["Int32"] = 2, ["UInt32"] = 2, ["Bcd32"] = 2, ["Float32"] = 2,
|
||||||
|
["Int64"] = 4, ["UInt64"] = 4, ["Float64"] = 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly HashSet<string> S7ArrayTypes = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly HashSet<string> S7ArrayTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||||
@@ -61,31 +79,43 @@ public static class DriverArrayWriteSupport
|
|||||||
/// constant); <see langword="null"/> or unknown ⇒ <see langword="false"/>.</param>
|
/// constant); <see langword="null"/> or unknown ⇒ <see langword="false"/>.</param>
|
||||||
/// <param name="dataType">The tag's OPC UA <c>DataType</c> name (<c>Tag.DataType</c>).</param>
|
/// <param name="dataType">The tag's OPC UA <c>DataType</c> name (<c>Tag.DataType</c>).</param>
|
||||||
/// <param name="tagConfig">The tag's <c>TagConfig</c> JSON (driver-native address + element type).</param>
|
/// <param name="tagConfig">The tag's <c>TagConfig</c> JSON (driver-native address + element type).</param>
|
||||||
|
/// <param name="driverConfigs">The <c>DriverConfig</c> / <c>DeviceConfig</c> JSON blobs that can carry a
|
||||||
|
/// driver write cap (Modbus <c>maxRegistersPerWrite</c>). Every one is consulted and the SMALLEST cap
|
||||||
|
/// wins, so the answer stays conservative however the driver's config merge resolves them.</param>
|
||||||
/// <returns><see langword="true"/> only when the driver encodes a whole-array write for this tag.</returns>
|
/// <returns><see langword="true"/> only when the driver encodes a whole-array write for this tag.</returns>
|
||||||
public static bool Supports(string? driverType, string? dataType, string? tagConfig)
|
public static bool Supports(string? driverType, string? dataType, string? tagConfig, params string?[] driverConfigs)
|
||||||
{
|
{
|
||||||
switch (driverType)
|
switch (driverType)
|
||||||
{
|
{
|
||||||
case DriverTypeNames.OpcUaClient:
|
case DriverTypeNames.OpcUaClient:
|
||||||
return true;
|
return true;
|
||||||
case DriverTypeNames.Galaxy:
|
case DriverTypeNames.Galaxy:
|
||||||
return dataType is not null && GalaxyArrayDataTypes.Contains(dataType);
|
return dataType is not null && GalaxyArrayDataTypes.Contains(dataType)
|
||||||
|
&& TryParseObject(tagConfig, out var galaxyRoot) && ReadArrayLength(galaxyRoot) is not null;
|
||||||
case DriverTypeNames.Modbus:
|
case DriverTypeNames.Modbus:
|
||||||
{
|
{
|
||||||
// Same reads as ModbusTagDefinitionFactory: an absent (or non-string) region defaults to
|
// Same reads as ModbusTagDefinitionFactory: a blob with no `region` or no numeric `address` is not
|
||||||
// HoldingRegisters and an absent dataType to Int16 (TagConfigJson.TryReadEnumStrict).
|
// a Modbus tag at all (the factory refuses it). A present non-string region is read as absent ⇒
|
||||||
if (!TryParseObject(tagConfig, out var root)) return false;
|
// HoldingRegisters, and an absent dataType ⇒ Int16 (TagConfigJson.TryReadEnumStrict).
|
||||||
|
if (!TryParseObject(tagConfig, out var root)
|
||||||
|
|| !root.TryGetProperty("region", out _)
|
||||||
|
|| !root.TryGetProperty("address", out var address) || address.ValueKind != JsonValueKind.Number
|
||||||
|
|| ReadArrayLength(root) is not { } count)
|
||||||
|
return false;
|
||||||
var region = ReadEnumName(root, "region") ?? "HoldingRegisters";
|
var region = ReadEnumName(root, "region") ?? "HoldingRegisters";
|
||||||
var native = ReadEnumName(root, "dataType") ?? "Int16";
|
var native = ReadEnumName(root, "dataType") ?? "Int16";
|
||||||
if (string.Equals(region, "Coils", StringComparison.OrdinalIgnoreCase)) return true;
|
if (string.Equals(region, "Coils", StringComparison.OrdinalIgnoreCase)) return count <= ModbusMaxCoilsPerWrite;
|
||||||
return string.Equals(region, "HoldingRegisters", StringComparison.OrdinalIgnoreCase)
|
return string.Equals(region, "HoldingRegisters", StringComparison.OrdinalIgnoreCase)
|
||||||
&& ModbusRegisterArrayTypes.Contains(native);
|
&& ModbusRegisterArrayTypes.TryGetValue(native, out var registersPerElement)
|
||||||
|
&& (long)count * registersPerElement <= ModbusMaxRegistersPerWrite(driverConfigs);
|
||||||
}
|
}
|
||||||
case DriverTypeNames.S7:
|
case DriverTypeNames.S7:
|
||||||
{
|
{
|
||||||
// S7TagDefinitionFactory requires a dataType key (absent ⇒ not an S7 tag at all); a present
|
// S7TagDefinitionFactory requires a dataType key (absent ⇒ not an S7 tag at all); a present
|
||||||
// non-string value falls back to Int16, exactly as the factory's strict enum read does.
|
// non-string value falls back to Int16, exactly as the factory's strict enum read does.
|
||||||
if (!TryParseObject(tagConfig, out var root) || !root.TryGetProperty("dataType", out _)) return false;
|
if (!TryParseObject(tagConfig, out var root) || !root.TryGetProperty("dataType", out _)
|
||||||
|
|| ReadArrayLength(root) is null)
|
||||||
|
return false;
|
||||||
var native = ReadEnumName(root, "dataType") ?? "Int16";
|
var native = ReadEnumName(root, "dataType") ?? "Int16";
|
||||||
return S7ArrayTypes.Contains(native);
|
return S7ArrayTypes.Contains(native);
|
||||||
}
|
}
|
||||||
@@ -94,6 +124,36 @@ public static class DriverArrayWriteSupport
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
int? cap = null;
|
||||||
|
foreach (var config in driverConfigs ?? [])
|
||||||
|
{
|
||||||
|
if (!TryParseObject(config, out var root)) continue;
|
||||||
|
foreach (var prop in root.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (string.Equals(prop.Name, "maxRegistersPerWrite", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& prop.Value.ValueKind == JsonValueKind.Number
|
||||||
|
&& prop.Value.TryGetInt32(out var configured) && configured > 0)
|
||||||
|
{
|
||||||
|
cap = Math.Min(cap ?? int.MaxValue, configured);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cap ?? ModbusDefaultMaxRegistersPerWrite;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The declared element count: isArray is the JSON literal true AND arrayLength is an Int32 >= 1 — the rule the
|
||||||
|
// Modbus and S7 tag factories apply. Anything else is a scalar to those drivers ⇒ null.
|
||||||
|
private static int? ReadArrayLength(JsonElement root) =>
|
||||||
|
root.TryGetProperty("isArray", out var isArray) && isArray.ValueKind == JsonValueKind.True
|
||||||
|
&& root.TryGetProperty("arrayLength", out var length) && length.ValueKind == JsonValueKind.Number
|
||||||
|
&& length.TryGetInt32(out var count) && count >= 1
|
||||||
|
? count
|
||||||
|
: null;
|
||||||
|
|
||||||
// Parses the TagConfig root object; a blank / malformed / non-object blob is "no tag" ⇒ false. The clone
|
// Parses the TagConfig root object; a blank / malformed / non-object blob is "no tag" ⇒ false. The clone
|
||||||
// detaches the element from the disposed document.
|
// detaches the element from the disposed document.
|
||||||
private static bool TryParseObject(string? tagConfig, out JsonElement root)
|
private static bool TryParseObject(string? tagConfig, out JsonElement root)
|
||||||
|
|||||||
@@ -39,8 +39,13 @@ internal static class MxValueEncoder
|
|||||||
case DateTimeOffset dto: return new MxValue { TimestampValue = Timestamp.FromDateTimeOffset(dto) };
|
case DateTimeOffset dto: return new MxValue { TimestampValue = Timestamp.FromDateTimeOffset(dto) };
|
||||||
|
|
||||||
case bool[] arr: return EncodeArray(arr, (mx, vs) => mx.BoolValues = ToBoolArray(vs));
|
case bool[] arr: return EncodeArray(arr, (mx, vs) => mx.BoolValues = ToBoolArray(vs));
|
||||||
case int[] arr: return EncodeArray(arr, (mx, vs) => mx.Int32Values = ToInt32Array(vs));
|
// Exact-type guards: CLR array covariance makes `uint[] is int[]` and `ulong[] is long[]` true, so
|
||||||
case long[] arr: return EncodeArray(arr, (mx, vs) => mx.Int64Values = ToInt64Array(vs));
|
// without them an unsigned array would be encoded by REINTERPRETING its bits (uint.MaxValue → -1) —
|
||||||
|
// unlike the range-checked scalar arms above. An unsigned array is refused instead (Gitea #610 review).
|
||||||
|
case int[] arr when arr.GetType() == typeof(int[]):
|
||||||
|
return EncodeArray(arr, (mx, vs) => mx.Int32Values = ToInt32Array(vs));
|
||||||
|
case long[] arr when arr.GetType() == typeof(long[]):
|
||||||
|
return EncodeArray(arr, (mx, vs) => mx.Int64Values = ToInt64Array(vs));
|
||||||
case float[] arr: return EncodeArray(arr, (mx, vs) => mx.FloatValues = ToFloatArray(vs));
|
case float[] arr: return EncodeArray(arr, (mx, vs) => mx.FloatValues = ToFloatArray(vs));
|
||||||
case double[] arr: return EncodeArray(arr, (mx, vs) => mx.DoubleValues = ToDoubleArray(vs));
|
case double[] arr: return EncodeArray(arr, (mx, vs) => mx.DoubleValues = ToDoubleArray(vs));
|
||||||
case string[] arr: return EncodeArray(arr, (mx, vs) => mx.StringValues = ToStringArray(vs));
|
case string[] arr: return EncodeArray(arr, (mx, vs) => mx.StringValues = ToStringArray(vs));
|
||||||
|
|||||||
@@ -668,7 +668,8 @@ public static class AddressSpaceComposer
|
|||||||
rawFolders, driverInstances, devices, tagGroups, tags, unsTagReferences,
|
rawFolders, driverInstances, devices, tagGroups, tags, unsTagReferences,
|
||||||
unsAreas, unsLines, equipment, topology);
|
unsAreas, unsLines, equipment, topology);
|
||||||
var unsReferenceVariables = BuildUnsReferenceVariables(
|
var unsReferenceVariables = BuildUnsReferenceVariables(
|
||||||
unsTagReferences, tags, unsAreas, unsLines, equipment, topology, driverInstances);
|
unsTagReferences, tags, unsAreas, unsLines, equipment, topology,
|
||||||
|
BuildArrayWriteCapability(driverInstances, devices));
|
||||||
|
|
||||||
return new AddressSpaceComposition(areas, lines, nodes, plans, alarms)
|
return new AddressSpaceComposition(areas, lines, nodes, plans, alarms)
|
||||||
{
|
{
|
||||||
@@ -767,7 +768,7 @@ public static class AddressSpaceComposer
|
|||||||
// TagId → the set of referencing equipment UNS folder paths (Area/Line/Equipment), for the
|
// TagId → the set of referencing equipment UNS folder paths (Area/Line/Equipment), for the
|
||||||
// native-alarm multi-notifier. Distinct + ordinal-sorted for determinism.
|
// native-alarm multi-notifier. Distinct + ordinal-sorted for determinism.
|
||||||
var equipPathByTag = BuildReferencingEquipmentPathsByTag(unsTagReferences, unsAreas, unsLines, equipment);
|
var equipPathByTag = BuildReferencingEquipmentPathsByTag(unsTagReferences, unsAreas, unsLines, equipment);
|
||||||
var driverTypeById = BuildDriverTypeById(driverInstances);
|
var arrayWritableFor = BuildArrayWriteCapability(driverInstances, devices);
|
||||||
|
|
||||||
var rawTags = new List<RawTagPlan>();
|
var rawTags = new List<RawTagPlan>();
|
||||||
foreach (var t in tags ?? Array.Empty<Tag>())
|
foreach (var t in tags ?? Array.Empty<Tag>())
|
||||||
@@ -779,8 +780,7 @@ public static class AddressSpaceComposer
|
|||||||
: topology.DevicePaths.GetValueOrDefault(t.DeviceId);
|
: topology.DevicePaths.GetValueOrDefault(t.DeviceId);
|
||||||
var driverInstanceId = topology.DriverIdByDevice.GetValueOrDefault(t.DeviceId, string.Empty);
|
var driverInstanceId = topology.DriverIdByDevice.GetValueOrDefault(t.DeviceId, string.Empty);
|
||||||
var intent = TagConfigIntent.Parse(t.TagConfig);
|
var intent = TagConfigIntent.Parse(t.TagConfig);
|
||||||
var arrayWritable = intent.IsArray && DriverArrayWriteSupport.Supports(
|
var arrayWritable = intent.IsArray && arrayWritableFor(t);
|
||||||
driverTypeById.GetValueOrDefault(driverInstanceId), t.DataType, t.TagConfig);
|
|
||||||
var alarm = intent.Alarm is { } a
|
var alarm = intent.Alarm is { } a
|
||||||
? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva)
|
? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva)
|
||||||
: null;
|
: null;
|
||||||
@@ -808,12 +808,30 @@ public static class AddressSpaceComposer
|
|||||||
return (containers, rawTags);
|
return (containers, rawTags);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary><c>DriverInstanceId → DriverType</c>, for the per-driver array-write capability (Gitea #610).
|
/// <summary>
|
||||||
/// First row wins on a duplicate id, matching the composer's other id maps.</summary>
|
/// The per-driver array-write capability (Gitea #610) as a per-tag predicate over the owning driver's
|
||||||
private static IReadOnlyDictionary<string, string> BuildDriverTypeById(IReadOnlyList<DriverInstance> driverInstances) =>
|
/// <c>DriverType</c>, the driver's <c>DriverConfig</c> and the tag's own device's <c>DeviceConfig</c>
|
||||||
driverInstances
|
/// (either config can carry a write cap such as Modbus <c>maxRegistersPerWrite</c>; the table takes the
|
||||||
|
/// smaller). First row wins on a duplicate id, matching the composer's other id maps. Built once per
|
||||||
|
/// compose and shared by the raw and UNS passes, so a mirror is writable exactly when its raw node is.
|
||||||
|
/// </summary>
|
||||||
|
private static Func<Tag, bool> BuildArrayWriteCapability(
|
||||||
|
IReadOnlyList<DriverInstance> driverInstances, IReadOnlyList<Device>? devices)
|
||||||
|
{
|
||||||
|
var driverById = driverInstances
|
||||||
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
|
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
|
||||||
.ToDictionary(g => g.Key, g => g.First().DriverType, StringComparer.Ordinal);
|
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
||||||
|
var deviceById = (devices ?? Array.Empty<Device>())
|
||||||
|
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
||||||
|
return tag =>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Emit the UNS reference Variables: one per <see cref="UnsTagReference"/> whose backing raw tag +
|
/// Emit the UNS reference Variables: one per <see cref="UnsTagReference"/> whose backing raw tag +
|
||||||
@@ -829,11 +847,10 @@ public static class AddressSpaceComposer
|
|||||||
IReadOnlyList<UnsLine> unsLines,
|
IReadOnlyList<UnsLine> unsLines,
|
||||||
IReadOnlyList<Equipment> equipment,
|
IReadOnlyList<Equipment> equipment,
|
||||||
RawTopology topology,
|
RawTopology topology,
|
||||||
IReadOnlyList<DriverInstance> driverInstances)
|
Func<Tag, bool> arrayWritableFor)
|
||||||
{
|
{
|
||||||
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
|
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
|
||||||
if (refs.Count == 0) return Array.Empty<UnsReferenceVariable>();
|
if (refs.Count == 0) return Array.Empty<UnsReferenceVariable>();
|
||||||
var driverTypeById = BuildDriverTypeById(driverInstances);
|
|
||||||
|
|
||||||
var tagsById = (tags ?? Array.Empty<Tag>())
|
var tagsById = (tags ?? Array.Empty<Tag>())
|
||||||
.GroupBy(t => t.TagId, StringComparer.Ordinal)
|
.GroupBy(t => t.TagId, StringComparer.Ordinal)
|
||||||
@@ -866,9 +883,7 @@ public static class AddressSpaceComposer
|
|||||||
var intent = TagConfigIntent.Parse(tag.TagConfig);
|
var intent = TagConfigIntent.Parse(tag.TagConfig);
|
||||||
// Same derivation as BuildRawSubtree's ArrayWritable, off the same row, so a mirror is writable
|
// Same derivation as BuildRawSubtree's ArrayWritable, off the same row, so a mirror is writable
|
||||||
// exactly when its raw node is.
|
// exactly when its raw node is.
|
||||||
var backingArrayWritable = intent.IsArray && DriverArrayWriteSupport.Supports(
|
var backingArrayWritable = intent.IsArray && arrayWritableFor(tag);
|
||||||
driverTypeById.GetValueOrDefault(topology.DriverIdByDevice.GetValueOrDefault(tag.DeviceId, string.Empty)),
|
|
||||||
tag.DataType, tag.TagConfig);
|
|
||||||
|
|
||||||
vars.Add(new UnsReferenceVariable(
|
vars.Add(new UnsReferenceVariable(
|
||||||
UnsTagReferenceId: r.UnsTagReferenceId,
|
UnsTagReferenceId: r.UnsTagReferenceId,
|
||||||
|
|||||||
@@ -2018,10 +2018,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
/// conservative — it only rejects when BOTH the expected and actual built-in types are confidently
|
/// conservative — it only rejects when BOTH the expected and actual built-in types are confidently
|
||||||
/// resolved AND differ (with numeric widening + the BaseDataType "accept anything" wildcard
|
/// resolved AND differ (with numeric widening + the BaseDataType "accept anything" wildcard
|
||||||
/// allowed); anything uncertain (a non-variable node, an unresolved/abstract DataType, a
|
/// allowed); anything uncertain (a non-variable node, an unresolved/abstract DataType, a
|
||||||
/// <see cref="Variant"/>/array payload whose element type isn't cheaply known) is allowed through so
|
/// <see cref="Variant"/> payload whose type isn't cheaply known) is allowed through.
|
||||||
/// the SDK's own (authoritative) type coercion in <c>BaseVariableState.WriteValue</c> remains the
|
/// </para>
|
||||||
/// final arbiter. We deliberately do NOT attempt deep array-dimension / structured-type validation
|
/// <para>
|
||||||
/// here — that is left to the SDK.
|
/// <b>⚠️ This is the ONLY type check a writable equipment node gets</b> (Gitea #610 review). The SDK's
|
||||||
|
/// <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"/>).
|
||||||
/// </para>
|
/// </para>
|
||||||
/// Pure (no SDK server / Lock needed): reads only <paramref name="value"/> and the node's static
|
/// 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.
|
/// <c>DataType</c>, so it is unit-testable in isolation.
|
||||||
@@ -2042,8 +2048,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
// 1-D array of its declared length: every array-capable driver refuses a short or long block (Modbus
|
// 1-D array of its declared length: every array-capable driver refuses a short or long block (Modbus
|
||||||
// FC15/FC16, S7's contiguous block), so a wrong shape is rejected here instead of being optimistically
|
// FC15/FC16, S7's contiguous block), so a wrong shape is rejected here instead of being optimistically
|
||||||
// applied, dispatched, refused and reverted. ArrayDimensions [0] / absent means unbounded ⇒ any length.
|
// applied, dispatched, refused and reverted. ArrayDimensions [0] / absent means unbounded ⇒ any length.
|
||||||
|
var isArrayNode = false;
|
||||||
if (node is BaseDataVariableState { ValueRank: ValueRanks.OneDimension } arrayNode)
|
if (node is BaseDataVariableState { ValueRank: ValueRanks.OneDimension } arrayNode)
|
||||||
{
|
{
|
||||||
|
isArrayNode = true;
|
||||||
if (value is not Array { Rank: 1 } array)
|
if (value is not Array { Rank: 1 } array)
|
||||||
{
|
{
|
||||||
return new ServiceResult(StatusCodes.BadTypeMismatch, "a 1-D array node takes a 1-D array value");
|
return new ServiceResult(StatusCodes.BadTypeMismatch, "a 1-D array node takes a 1-D array value");
|
||||||
@@ -2064,14 +2072,43 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
var expected = TypeInfo.GetBuiltInType(variable.DataType); // NodeId ⇒ built-in; abstract/unknown ⇒ Null
|
var expected = TypeInfo.GetBuiltInType(variable.DataType); // NodeId ⇒ built-in; abstract/unknown ⇒ Null
|
||||||
if (expected is BuiltInType.Null or BuiltInType.Variant or BuiltInType.DataValue) return null; // unresolved / wildcard
|
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[])
|
||||||
|
{
|
||||||
|
return new ServiceResult(StatusCodes.BadTypeMismatch, "a scalar node takes a scalar value");
|
||||||
|
}
|
||||||
|
|
||||||
// SAFETY BOUNDARY: only reject against the CLOSED set of built-in types a writable equipment node can
|
// SAFETY BOUNDARY: only reject against the CLOSED set of built-in types a writable equipment node can
|
||||||
// actually carry (per ResolveBuiltInDataType). Any other expected type (Enumeration, Guid, NodeId,
|
// actually carry (per ResolveBuiltInDataType). Any other expected type (Enumeration, Guid, NodeId,
|
||||||
// StatusCode, …) is DEFERRED to the SDK's authoritative coercion so this fail-fast can NEVER false-reject
|
// StatusCode, …) is DEFERRED to the SDK's authoritative coercion so this fail-fast can NEVER false-reject
|
||||||
// a write the SDK would have accepted (e.g. an Int32 payload to an Enumeration node coerces fine).
|
// a write the SDK would have accepted (e.g. an Int32 payload to an Enumeration node coerces fine).
|
||||||
if (!IsCheckableExpectedType(expected)) return null;
|
if (!IsCheckableExpectedType(expected)) return null;
|
||||||
|
|
||||||
// TypeInfo.Construct(object) classifies the runtime payload; an unclassifiable value ⇒ Unknown (Null).
|
// TypeInfo.Construct(object) classifies the runtime payload (an array by its ELEMENT type); an
|
||||||
|
// unclassifiable value ⇒ Unknown (Null).
|
||||||
var actual = TypeInfo.Construct(value).BuiltInType;
|
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.
|
||||||
|
if (isArrayNode)
|
||||||
|
{
|
||||||
|
return actual == expected || (expected, actual) is (BuiltInType.Byte, BuiltInType.ByteString)
|
||||||
|
? null
|
||||||
|
: 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
|
if (actual == BuiltInType.Null) return null; // couldn't classify the payload ⇒ defer to the SDK
|
||||||
|
|
||||||
if (!IsBuiltInTypeCompatible(expected, actual))
|
if (!IsBuiltInTypeCompatible(expected, actual))
|
||||||
@@ -2099,9 +2136,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
: new ServiceResult(StatusCodes.BadWriteNotSupported, "IndexRange writes are not supported; write the whole array");
|
: new ServiceResult(StatusCodes.BadWriteNotSupported, "IndexRange writes are not supported; write the whole array");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Conservative built-in-type compatibility test for <see cref="EvaluateEquipmentWriteStructure"/>.
|
/// Conservative built-in-type compatibility test for <see cref="EvaluateEquipmentWriteStructure"/>'s
|
||||||
/// Returns true (compatible — allow through) unless there is a confident mismatch. Exact matches pass;
|
/// SCALAR path (array writes are matched exactly — see there). Returns true (compatible — allow through)
|
||||||
/// numeric-to-numeric is treated as compatible (the SDK widens/narrows numerics); a
|
/// unless there is a confident mismatch. Exact matches pass; numeric-to-numeric is treated as compatible —
|
||||||
|
/// deliberately looser than the SDK, which would refuse it: 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
|
||||||
|
/// (<c>Convert.ToInt16</c>, MxValueEncoder's <c>uint ≤ int.MaxValue</c> arm, …), so an out-of-range value
|
||||||
|
/// fails the device write and is reverted rather than silently reinterpreted; a
|
||||||
/// <see cref="BuiltInType.ByteString"/>↔<see cref="BuiltInType.Byte"/> array nuance and any
|
/// <see cref="BuiltInType.ByteString"/>↔<see cref="BuiltInType.Byte"/> array nuance and any
|
||||||
/// <see cref="BuiltInType.Variant"/> on either side are treated as compatible. Only a clear cross-family
|
/// <see cref="BuiltInType.Variant"/> on either side are treated as compatible. Only a clear cross-family
|
||||||
/// mismatch (e.g. writing a String to a Boolean node) returns false. Pure + static.
|
/// mismatch (e.g. writing a String to a Boolean node) returns false. Pure + static.
|
||||||
|
|||||||
+92
-20
@@ -6,27 +6,78 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gitea #610 — the per-driver array-write capability table. The table's agreement with each listed driver's
|
/// Gitea #610 — the per-driver array-write capability table. The table's agreement with each listed driver's
|
||||||
/// real encoder is pinned in that driver's own test project (<c>*ArrayWriteSupportParityTests</c>); these
|
/// real encoder is pinned in that driver's own test project (<c>*ArrayWriteSupportParityTests</c>); these
|
||||||
/// tests pin the table's own reading of TagConfig (defaults, numeric enums, malformed blobs).
|
/// tests pin the table's own reading of TagConfig (defaults, numeric enums, malformed blobs, declared length,
|
||||||
|
/// write caps).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class DriverArrayWriteSupportTests
|
public sealed class DriverArrayWriteSupportTests
|
||||||
{
|
{
|
||||||
|
private const string Arr2 = ",\"isArray\":true,\"arrayLength\":2";
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("{\"region\":\"Coils\",\"address\":0,\"dataType\":\"Bool\"}", true)]
|
[InlineData("{\"region\":\"Coils\",\"address\":0,\"dataType\":\"Bool\"" + Arr2 + "}", true)]
|
||||||
[InlineData("{\"region\":\"Coils\",\"address\":0}", true)] // FC15 ignores dataType
|
[InlineData("{\"region\":\"Coils\",\"address\":0" + Arr2 + "}", true)] // FC15 ignores dataType
|
||||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Float32\"}", true)]
|
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Float32\"" + Arr2 + "}", true)]
|
||||||
[InlineData("{\"region\":\"holdingregisters\",\"address\":0,\"dataType\":\"bcd16\"}", true)] // case-insensitive, like the driver
|
[InlineData("{\"region\":\"holdingregisters\",\"address\":0,\"dataType\":\"bcd16\"" + Arr2 + "}", true)] // case-insensitive, like the driver
|
||||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0}", true)] // dataType ⇒ Int16
|
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0" + Arr2 + "}", true)] // dataType ⇒ Int16
|
||||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"BitInRegister\"}", false)]
|
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"BitInRegister\"" + Arr2 + "}", false)]
|
||||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"String\"}", false)]
|
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"String\"" + Arr2 + "}", false)]
|
||||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Bool\"}", false)]
|
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Bool\"" + Arr2 + "}", false)]
|
||||||
[InlineData("{\"region\":\"InputRegisters\",\"address\":0,\"dataType\":\"Int16\"}", false)] // read-only region
|
[InlineData("{\"region\":\"InputRegisters\",\"address\":0,\"dataType\":\"Int16\"" + Arr2 + "}", false)] // read-only region
|
||||||
[InlineData("{\"region\":\"DiscreteInputs\",\"address\":0}", false)]
|
[InlineData("{\"region\":\"DiscreteInputs\",\"address\":0" + Arr2 + "}", false)]
|
||||||
[InlineData("{\"region\":0,\"address\":0,\"dataType\":\"Int16\"}", true)] // numeric ⇒ absent ⇒ HoldingRegisters (driver reads it that way)
|
[InlineData("{\"region\":0,\"address\":0,\"dataType\":\"Int16\"" + Arr2 + "}", true)] // numeric ⇒ absent ⇒ HoldingRegisters (driver reads it that way)
|
||||||
|
[InlineData("{\"address\":0,\"dataType\":\"Int16\"" + Arr2 + "}", false)] // no region ⇒ the factory refuses the tag
|
||||||
|
[InlineData("{\"region\":\"HoldingRegisters\",\"dataType\":\"Int16\"" + Arr2 + "}", false)] // no address ⇒ likewise
|
||||||
|
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Int16\",\"isArray\":true}", false)] // no length ⇒ scalar to the driver
|
||||||
|
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":0}", false)]
|
||||||
|
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":\"4\"}", false)] // non-numeric length
|
||||||
|
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":1}", true)] // 1-element array is valid
|
||||||
[InlineData("not json", false)]
|
[InlineData("not json", false)]
|
||||||
[InlineData("", false)]
|
[InlineData("", false)]
|
||||||
public void Modbus_rows_follow_region_and_element_type(string tagConfig, bool expected) =>
|
public void Modbus_rows_follow_region_element_type_and_declared_length(string tagConfig, bool expected) =>
|
||||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tagConfig).ShouldBe(expected);
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tagConfig).ShouldBe(expected);
|
||||||
|
|
||||||
|
/// <summary>#610 review MINOR-1 — the driver refuses (does not chunk) an FC16 over <c>maxRegistersPerWrite</c>
|
||||||
|
/// (default 123), so an array whose registers cannot fit is never advertised writable.</summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Int16", 123, true)]
|
||||||
|
[InlineData("Int16", 124, false)]
|
||||||
|
[InlineData("Int32", 61, true)] // 122 registers
|
||||||
|
[InlineData("Int32", 62, false)] // 124 registers
|
||||||
|
[InlineData("Float64", 30, true)] // 120 registers
|
||||||
|
[InlineData("Float64", 31, false)] // 124 registers
|
||||||
|
public void Modbus_register_arrays_respect_the_default_write_cap(string dataType, int length, bool expected) =>
|
||||||
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray(dataType, length)).ShouldBe(expected);
|
||||||
|
|
||||||
|
/// <summary>A configured cap (driver- or device-level, case-insensitive) is honoured; when both are visible the
|
||||||
|
/// smaller wins, and a non-positive value means the driver default.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Modbus_register_arrays_respect_a_configured_write_cap()
|
||||||
|
{
|
||||||
|
var tag = RegisterArray("Int16", 10);
|
||||||
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tag, "{\"maxRegistersPerWrite\":10}").ShouldBeTrue();
|
||||||
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tag, "{\"MaxRegistersPerWrite\":9}").ShouldBeFalse();
|
||||||
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tag, "{\"maxRegistersPerWrite\":100}", "{\"maxRegistersPerWrite\":9}")
|
||||||
|
.ShouldBeFalse();
|
||||||
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tag, "{\"maxRegistersPerWrite\":0}", null, "not json").ShouldBeTrue();
|
||||||
|
// A larger configured cap lifts the default: 124 registers fit under 200.
|
||||||
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray("Int16", 124), "{\"maxRegistersPerWrite\":200}")
|
||||||
|
.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>FC15 carries at most 1968 coils and the driver sends one request, so a longer coil array is not
|
||||||
|
/// writable.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Modbus_coil_arrays_respect_the_FC15_ceiling()
|
||||||
|
{
|
||||||
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean",
|
||||||
|
"{\"region\":\"Coils\",\"address\":0,\"isArray\":true,\"arrayLength\":1968}").ShouldBeTrue();
|
||||||
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean",
|
||||||
|
"{\"region\":\"Coils\",\"address\":0,\"isArray\":true,\"arrayLength\":1969}").ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RegisterArray(string dataType, int length) =>
|
||||||
|
$"{{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"{dataType}\",\"isArray\":true,\"arrayLength\":{length}}}";
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("Byte", true)]
|
[InlineData("Byte", true)]
|
||||||
[InlineData("Int16", true)]
|
[InlineData("Int16", true)]
|
||||||
@@ -41,12 +92,23 @@ public sealed class DriverArrayWriteSupportTests
|
|||||||
[InlineData("String", false)]
|
[InlineData("String", false)]
|
||||||
[InlineData("DateTime", false)]
|
[InlineData("DateTime", false)]
|
||||||
public void S7_rows_follow_the_element_type(string s7Type, bool expected) =>
|
public void S7_rows_follow_the_element_type(string s7Type, bool expected) =>
|
||||||
DriverArrayWriteSupport.Supports(DriverTypeNames.S7, "Int16", $"{{\"address\":\"DB1.DBW0\",\"dataType\":\"{s7Type}\"}}")
|
DriverArrayWriteSupport.Supports(DriverTypeNames.S7, "Int16", $"{{\"address\":\"DB1.DBW0\",\"dataType\":\"{s7Type}\"{Arr2}}}")
|
||||||
.ShouldBe(expected);
|
.ShouldBe(expected);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void S7_without_a_dataType_key_is_not_an_S7_tag() =>
|
public void S7_without_a_dataType_key_is_not_an_S7_tag() =>
|
||||||
DriverArrayWriteSupport.Supports(DriverTypeNames.S7, "Int16", "{\"address\":\"DB1.DBW0\"}").ShouldBeFalse();
|
DriverArrayWriteSupport.Supports(DriverTypeNames.S7, "Int16", "{\"address\":\"DB1.DBW0\"" + Arr2 + "}").ShouldBeFalse();
|
||||||
|
|
||||||
|
/// <summary>#610 review MINOR-2 — S7 maps isArray without a positive integer arrayLength to a SCALAR tag.</summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("", false)]
|
||||||
|
[InlineData(",\"isArray\":true", false)]
|
||||||
|
[InlineData(",\"isArray\":true,\"arrayLength\":0", false)]
|
||||||
|
[InlineData(",\"isArray\":false,\"arrayLength\":4", false)]
|
||||||
|
[InlineData(",\"isArray\":true,\"arrayLength\":4", true)]
|
||||||
|
public void S7_requires_a_declared_length(string shape, bool expected) =>
|
||||||
|
DriverArrayWriteSupport.Supports(DriverTypeNames.S7, "Int16", $"{{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\"{shape}}}")
|
||||||
|
.ShouldBe(expected);
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("Boolean", true)]
|
[InlineData("Boolean", true)]
|
||||||
@@ -60,7 +122,17 @@ public sealed class DriverArrayWriteSupportTests
|
|||||||
[InlineData("UInt32", false)]
|
[InlineData("UInt32", false)]
|
||||||
[InlineData("Byte", false)]
|
[InlineData("Byte", false)]
|
||||||
public void Galaxy_rows_follow_the_opc_ua_data_type(string dataType, bool expected) =>
|
public void Galaxy_rows_follow_the_opc_ua_data_type(string dataType, bool expected) =>
|
||||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, dataType, "{\"FullName\":\"Obj.Attr\"}").ShouldBe(expected);
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, dataType, "{\"FullName\":\"Obj.Attr\"" + Arr2 + "}").ShouldBe(expected);
|
||||||
|
|
||||||
|
/// <summary>#610 review MINOR-4 — with no declared length the node is unbounded and a short whole-array write
|
||||||
|
/// would RESIZE the Galaxy attribute, so an undeclared-length Galaxy array stays read-only.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Galaxy_requires_a_declared_length()
|
||||||
|
{
|
||||||
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, "Int32", "{\"FullName\":\"Obj.Attr\",\"isArray\":true}").ShouldBeFalse();
|
||||||
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, "Int32", "{\"FullName\":\"Obj.Attr\",\"isArray\":true,\"arrayLength\":0}").ShouldBeFalse();
|
||||||
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, "Int32", "{\"FullName\":\"Obj.Attr\",\"isArray\":true,\"arrayLength\":3}").ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void OpcUaClient_forwards_every_array() =>
|
public void OpcUaClient_forwards_every_array() =>
|
||||||
@@ -73,15 +145,15 @@ public sealed class DriverArrayWriteSupportTests
|
|||||||
var listed = new[] { DriverTypeNames.Modbus, DriverTypeNames.S7, DriverTypeNames.OpcUaClient, DriverTypeNames.Galaxy };
|
var listed = new[] { DriverTypeNames.Modbus, DriverTypeNames.S7, DriverTypeNames.OpcUaClient, DriverTypeNames.Galaxy };
|
||||||
var others = DriverTypeNames.All.Except(listed).ToList();
|
var others = DriverTypeNames.All.Except(listed).ToList();
|
||||||
others.ShouldNotBeEmpty();
|
others.ShouldNotBeEmpty();
|
||||||
const string blob = "{\"region\":\"Coils\",\"dataType\":\"Int32\"}";
|
// A blob every listed driver would accept, so only the driver type can make the answer false.
|
||||||
|
const string blob = "{\"region\":\"Coils\",\"address\":0,\"dataType\":\"Int32\"" + Arr2 + "}";
|
||||||
// Positive control: the same blob IS array-writable under the listed drivers that read it.
|
// Positive control: the same blob IS array-writable under the listed drivers that read it.
|
||||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int32", blob).ShouldBeTrue();
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int32", blob).ShouldBeTrue();
|
||||||
DriverArrayWriteSupport.Supports(DriverTypeNames.S7, "Int32", blob).ShouldBeTrue();
|
DriverArrayWriteSupport.Supports(DriverTypeNames.S7, "Int32", blob).ShouldBeTrue();
|
||||||
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, "Int32", blob).ShouldBeTrue();
|
||||||
foreach (var driverType in others.Append("NoSuchDriver").Append(null))
|
foreach (var driverType in others.Append("NoSuchDriver").Append(null))
|
||||||
{
|
{
|
||||||
// A blob every listed driver would accept, so only the driver type can make the answer false.
|
DriverArrayWriteSupport.Supports(driverType, "Int32", blob).ShouldBeFalse(driverType ?? "<null>");
|
||||||
DriverArrayWriteSupport.Supports(driverType, "Int32", "{\"region\":\"Coils\",\"dataType\":\"Int32\"}")
|
|
||||||
.ShouldBeFalse(driverType ?? "<null>");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-13
@@ -7,10 +7,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gitea #610 — pins <see cref="DriverArrayWriteSupport"/>'s Galaxy row against the REAL write encoder
|
/// Gitea #610 — pins <see cref="DriverArrayWriteSupport"/>'s Galaxy row against the REAL write encoder
|
||||||
/// (<see cref="MxValueEncoder"/>). The OPC UA server hands the driver the CLR array type of the node's
|
/// (<see cref="MxValueEncoder"/>). The driver receives the CLR array type of the node's <c>DataType</c> and no
|
||||||
/// <c>DataType</c> (the SDK refuses a mistyped array before the write handler runs), so for every
|
/// other: NOT because the SDK checks it (it skips its own type check on a node with an <c>OnWriteValue</c>
|
||||||
/// <c>Tag.DataType</c> the server can materialise, the node is advertised writable exactly when the encoder
|
/// handler, which every writable equipment node has) but because <c>OtOpcUaNodeManager.EvaluateEquipmentWriteStructure</c>
|
||||||
/// accepts that array type.
|
/// refuses an array whose element type does not match the node's DataType exactly (#610 review MAJOR-1). So
|
||||||
|
/// for every <c>Tag.DataType</c> the server can materialise, the node is advertised writable exactly when the
|
||||||
|
/// encoder accepts that array type.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Trait("Category", "Unit")]
|
[Trait("Category", "Unit")]
|
||||||
public sealed class GalaxyArrayWriteSupportParityTests
|
public sealed class GalaxyArrayWriteSupportParityTests
|
||||||
@@ -50,14 +52,18 @@ public sealed class GalaxyArrayWriteSupportParityTests
|
|||||||
encoderAccepts = false;
|
encoderAccepts = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// UInt32 / UInt64 are a deliberate exclusion: CLR array covariance lets uint[] / ulong[] match the encoder's
|
// UInt32 / UInt64 used to be a hand-written exclusion here, because CLR array covariance let uint[] /
|
||||||
// int[] / long[] arms, so it "accepts" them by REINTERPRETING the bits (uint.MaxValue → -1) — unlike the
|
// ulong[] match the encoder's int[] / long[] arms and be encoded by reinterpreting their bits. The encoder
|
||||||
// scalar arms, which range-check. A Galaxy attribute is never unsigned (DataTypeMap), so the table refuses
|
// now refuses them itself, so the table and the encoder agree with no special case.
|
||||||
// rather than advertise a write that silently corrupts large values.
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, dataType, "{\"FullName\":\"Obj.Attr\",\"isArray\":true,\"arrayLength\":2}")
|
||||||
var expected = encoderAccepts && dataType is not ("UInt32" or "UInt64");
|
.ShouldBe(encoderAccepts, dataType);
|
||||||
if (dataType is "UInt32" or "UInt64") encoderAccepts.ShouldBeTrue("the covariance hazard this exclusion documents");
|
|
||||||
|
|
||||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, dataType, "{\"FullName\":\"Obj.Attr\"}")
|
|
||||||
.ShouldBe(expected, dataType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Positive control: the theory above sees both answers, so it is not vacuously all-refused.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void The_matrix_contains_both_answers() =>
|
||||||
|
ClrArrays.Keys
|
||||||
|
.Select(dt => DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, dt, "{\"isArray\":true,\"arrayLength\":2}"))
|
||||||
|
.ToHashSet()
|
||||||
|
.ShouldBe(new[] { true, false }, ignoreOrder: true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,21 @@ public sealed class MxValueEncoderTests
|
|||||||
v.ArrayValue.TimestampValues.Values[0].ToDateTime().ShouldBe(inputs[0]);
|
v.ArrayValue.TimestampValues.Values[0].ToDateTime().ShouldBe(inputs[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #610 review — CLR array covariance makes <c>uint[] is int[]</c> and <c>ulong[] is long[]</c> true,
|
||||||
|
/// so the int[]/long[] arms used to encode an unsigned array by reinterpreting its bits (4,000,000,000 →
|
||||||
|
/// -294,967,296). An unsigned array is refused instead, like an out-of-range unsigned scalar.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Encode_UnsignedArrays_AreRefused_NotReinterpreted()
|
||||||
|
{
|
||||||
|
Should.Throw<ArgumentException>(() => MxValueEncoder.Encode(new uint[] { 4_000_000_000 }));
|
||||||
|
Should.Throw<ArgumentException>(() => MxValueEncoder.Encode(new ulong[] { ulong.MaxValue }));
|
||||||
|
// Positive control: the signed arrays still encode.
|
||||||
|
MxValueEncoder.Encode(new[] { -1 }).ArrayValue.Int32Values.Values.ShouldBe(new[] { -1 });
|
||||||
|
MxValueEncoder.Encode(new[] { -1L }).ArrayValue.Int64Values.Values.ShouldBe(new[] { -1L });
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that encoding an unsupported type throws an exception.</summary>
|
/// <summary>Verifies that encoding an unsupported type throws an exception.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Encode_UnsupportedType_Throws()
|
public void Encode_UnsupportedType_Throws()
|
||||||
|
|||||||
+47
-1
@@ -51,8 +51,54 @@ public sealed class ModbusArrayWriteSupportParityTests
|
|||||||
var answers = Shapes()
|
var answers = Shapes()
|
||||||
.Select(s => DriverArrayWriteSupport.Supports(
|
.Select(s => DriverArrayWriteSupport.Supports(
|
||||||
DriverTypeNames.Modbus, "Int16",
|
DriverTypeNames.Modbus, "Int16",
|
||||||
$$"""{"region":"{{s.Region}}","address":10,"dataType":"{{s.DataType}}"}"""))
|
$$"""{"region":"{{s.Region}}","address":10,"dataType":"{{s.DataType}}","isArray":true,"arrayLength":2}"""))
|
||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
answers.ShouldBe(new[] { true, false }, ignoreOrder: true);
|
answers.ShouldBe(new[] { true, false }, ignoreOrder: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #610 review MINOR-1/-2 — the table must also agree with the driver on the SHAPE edges: the FC16 write cap
|
||||||
|
/// (the driver refuses rather than chunks an over-cap array; default 123 registers, or the configured
|
||||||
|
/// <c>MaxRegistersPerWrite</c>) and a missing / zero <c>arrayLength</c> (the factory maps it to a scalar tag,
|
||||||
|
/// whose scalar encoder refuses an array value).
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Int32", "\"arrayLength\":61", 0, 61)] // 122 registers — fits the default cap
|
||||||
|
[InlineData("Int32", "\"arrayLength\":62", 0, 62)] // 124 registers — over it
|
||||||
|
[InlineData("Float64", "\"arrayLength\":31", 0, 31)] // 124 registers
|
||||||
|
[InlineData("Int16", "\"arrayLength\":10", 10, 10)] // exactly a configured cap
|
||||||
|
[InlineData("Int16", "\"arrayLength\":11", 10, 11)] // one over it
|
||||||
|
[InlineData("Int16", "\"arrayLength\":0", 0, 2)] // declared length 0 ⇒ scalar to the driver
|
||||||
|
[InlineData("Int16", "\"isArrayLengthMissing\":true", 0, 2)]
|
||||||
|
public async Task Table_agrees_with_the_driver_on_write_cap_and_declared_length(
|
||||||
|
string dataType, string lengthProp, ushort maxRegistersPerWrite, int elements)
|
||||||
|
{
|
||||||
|
var json = $$"""{"region":"HoldingRegisters","address":10,"dataType":"{{dataType}}","isArray":true,{{lengthProp}}}""";
|
||||||
|
var driverConfig = maxRegistersPerWrite == 0 ? "{}" : $$"""{"maxRegistersPerWrite":{{maxRegistersPerWrite}}}""";
|
||||||
|
var fake = new ModbusDriverTests.FakeTransport();
|
||||||
|
var options = new ModbusDriverOptions
|
||||||
|
{
|
||||||
|
Host = "fake", RawTags = [new RawTagEntry("cell/modbus/arr", json, false)],
|
||||||
|
MaxRegistersPerWrite = maxRegistersPerWrite == 0 ? (ushort)123 : maxRegistersPerWrite,
|
||||||
|
};
|
||||||
|
var drv = new ModbusDriver(options, "modbus-array-cap-parity", _ => fake);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var value = Enumerable.Repeat<object>(1, elements).ToArray();
|
||||||
|
var result = await drv.WriteAsync([new WriteRequest("cell/modbus/arr", value)], CancellationToken.None);
|
||||||
|
var driverAccepts = result.Single().StatusCode == 0u;
|
||||||
|
|
||||||
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", json, driverConfig).ShouldBe(
|
||||||
|
driverAccepts, $"{dataType} {lengthProp} cap={maxRegistersPerWrite}: driver status 0x{result.Single().StatusCode:X8}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Positive control for the theory above: both answers occur, so it is not vacuously all-refused.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void The_shape_edges_contain_both_answers()
|
||||||
|
{
|
||||||
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int32",
|
||||||
|
"""{"region":"HoldingRegisters","address":10,"dataType":"Int32","isArray":true,"arrayLength":61}""").ShouldBeTrue();
|
||||||
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int32",
|
||||||
|
"""{"region":"HoldingRegisters","address":10,"dataType":"Int32","isArray":true,"arrayLength":62}""").ShouldBeFalse();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ public sealed class ArrayWriteWireTests
|
|||||||
private const string S7ArrayRaw = "Plant/S7a/plc1/Levels";
|
private const string S7ArrayRaw = "Plant/S7a/plc1/Levels";
|
||||||
private const string AbArrayRaw = "Plant/Ab1/plc2/Counts";
|
private const string AbArrayRaw = "Plant/Ab1/plc2/Counts";
|
||||||
private const string S7ArrayUns = "filling/line-1/station-1/Levels";
|
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 sealed class RecordingWriteGateway(bool succeed) : IOpcUaNodeWriteGateway
|
private sealed class RecordingWriteGateway(bool succeed) : IOpcUaNodeWriteGateway
|
||||||
{
|
{
|
||||||
@@ -65,6 +67,24 @@ public sealed class ArrayWriteWireTests
|
|||||||
};
|
};
|
||||||
var plc1 = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-s7", Name = "plc1", DeviceConfig = "{}" };
|
var plc1 = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-s7", Name = "plc1", DeviceConfig = "{}" };
|
||||||
var plc2 = new Device { DeviceId = "dev-2", DriverInstanceId = "drv-ab", Name = "plc2", DeviceConfig = "{}" };
|
var plc2 = new Device { DeviceId = "dev-2", DriverInstanceId = "drv-ab", Name = "plc2", DeviceConfig = "{}" };
|
||||||
|
var gx = new DriverInstance
|
||||||
|
{
|
||||||
|
DriverInstanceId = "drv-gx", ClusterId = "c1", RawFolderId = "raw-plant",
|
||||||
|
Name = "Gx1", DriverType = "GalaxyMxGateway", DriverConfig = "{}",
|
||||||
|
};
|
||||||
|
var gxDevice = new Device { DeviceId = "dev-3", DriverInstanceId = "drv-gx", Name = "gx", DeviceConfig = "{}" };
|
||||||
|
var recipe = new Tag
|
||||||
|
{
|
||||||
|
TagId = "t-recipe", DeviceId = "dev-3", Name = "Recipe", DataType = "Int32",
|
||||||
|
AccessLevel = TagAccessLevel.ReadWrite,
|
||||||
|
TagConfig = "{\"FullName\":\"Mixer_001.Recipe\",\"isArray\":true,\"arrayLength\":2}",
|
||||||
|
};
|
||||||
|
var setpoint = new Tag
|
||||||
|
{
|
||||||
|
TagId = "t-setpoint", DeviceId = "dev-1", Name = "Setpoint", DataType = "Int16",
|
||||||
|
AccessLevel = TagAccessLevel.ReadWrite,
|
||||||
|
TagConfig = "{\"address\":\"DB1.DBW20\",\"dataType\":\"Int16\"}",
|
||||||
|
};
|
||||||
var levels = new Tag
|
var levels = new Tag
|
||||||
{
|
{
|
||||||
TagId = "t-levels", DeviceId = "dev-1", Name = "Levels", DataType = "Int16",
|
TagId = "t-levels", DeviceId = "dev-1", Name = "Levels", DataType = "Int16",
|
||||||
@@ -83,9 +103,9 @@ public sealed class ArrayWriteWireTests
|
|||||||
var reference = new UnsTagReference { UnsTagReferenceId = "ref-levels", EquipmentId = "eq-1", TagId = "t-levels" };
|
var reference = new UnsTagReference { UnsTagReferenceId = "ref-levels", EquipmentId = "eq-1", TagId = "t-levels" };
|
||||||
|
|
||||||
var composition = AddressSpaceComposer.Compose(
|
var composition = AddressSpaceComposer.Compose(
|
||||||
[area], [line], [equip], [s7, ab], Array.Empty<ScriptedAlarm>(),
|
[area], [line], [equip], [s7, ab, gx], Array.Empty<ScriptedAlarm>(),
|
||||||
unsTagReferences: [reference], tags: [levels, counts],
|
unsTagReferences: [reference], tags: [levels, counts, recipe, setpoint],
|
||||||
rawFolders: [folder], devices: [plc1, plc2]);
|
rawFolders: [folder], devices: [plc1, plc2, gxDevice]);
|
||||||
|
|
||||||
var applier = new AddressSpaceApplier(rig.Sink, NullLogger<AddressSpaceApplier>.Instance);
|
var applier = new AddressSpaceApplier(rig.Sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||||
applier.MaterialiseHierarchy(composition).ShouldBe(0);
|
applier.MaterialiseHierarchy(composition).ShouldBe(0);
|
||||||
@@ -96,6 +116,8 @@ public sealed class ArrayWriteWireTests
|
|||||||
rig.Sink.WriteValue(S7ArrayRaw, new short[] { 1, 2, 3, 4 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
|
rig.Sink.WriteValue(S7ArrayRaw, new short[] { 1, 2, 3, 4 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
|
||||||
rig.Sink.WriteValue(S7ArrayUns, new short[] { 1, 2, 3, 4 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns);
|
rig.Sink.WriteValue(S7ArrayUns, new short[] { 1, 2, 3, 4 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns);
|
||||||
rig.Sink.WriteValue(AbArrayRaw, new[] { 5, 6, 7, 8 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
|
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);
|
||||||
return rig;
|
return rig;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,4 +244,54 @@ public sealed class ArrayWriteWireTests
|
|||||||
(await WriteAsync(session, raw, new short[] { 7, 7, 7, 7 }, ct)).ShouldBe((StatusCode)StatusCodes.Good);
|
(await WriteAsync(session, raw, new short[] { 7, 7, 7, 7 }, ct)).ShouldBe((StatusCode)StatusCodes.Good);
|
||||||
gateway.Count.ShouldBe(1);
|
gateway.Count.ShouldBe(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_mistyped_array_write_is_refused_inline_and_never_reaches_the_driver()
|
||||||
|
{
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
await using var rig = await StartAsync("array-type", 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);
|
||||||
|
var galaxy = Raw(session, GalaxyArrayRaw);
|
||||||
|
|
||||||
|
(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, 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 });
|
||||||
|
gateway.Count.ShouldBe(0);
|
||||||
|
|
||||||
|
// Positive control: the correctly-typed array on the same Galaxy node is dispatched as-is.
|
||||||
|
(await WriteAsync(session, galaxy, new[] { 21, 22 }, ct)).ShouldBe((StatusCode)StatusCodes.Good);
|
||||||
|
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>
|
||||||
|
[Fact]
|
||||||
|
public async Task An_array_value_to_a_scalar_node_is_refused_inline()
|
||||||
|
{
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
await using var rig = await StartAsync("array-to-scalar", ct);
|
||||||
|
var gateway = new RecordingWriteGateway(succeed: true);
|
||||||
|
rig.Nm.NodeWriteGateway = gateway;
|
||||||
|
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||||
|
var scalar = Raw(session, S7ScalarRaw);
|
||||||
|
|
||||||
|
(await WriteAsync(session, scalar, new short[] { 9 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
|
||||||
|
((short)(await session.ReadValueAsync(scalar, ct)).Value).ShouldBe((short)5);
|
||||||
|
gateway.Count.ShouldBe(0);
|
||||||
|
|
||||||
|
// Positive control: a scalar write to the same node is dispatched.
|
||||||
|
(await WriteAsync(session, scalar, (short)9, ct)).ShouldBe((StatusCode)StatusCodes.Good);
|
||||||
|
gateway.Count.ShouldBe(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
@@ -307,4 +307,36 @@ public sealed class AddressSpaceComposerDualNamespaceTests
|
|||||||
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r1").BackingArrayWritable.ShouldBeTrue();
|
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r1").BackingArrayWritable.ShouldBeTrue();
|
||||||
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r3").BackingArrayWritable.ShouldBeFalse();
|
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r3").BackingArrayWritable.ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>#610 review MINOR-1 — the composer hands the table the owning driver's <c>DriverConfig</c> AND the
|
||||||
|
/// tag's own device's <c>DeviceConfig</c>, so a Modbus write cap configured at either level keeps an array that
|
||||||
|
/// can never fit read-only — on the raw node and its UNS mirror alike. The same tag under an uncapped device is
|
||||||
|
/// the positive control.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Array_write_capability_honours_a_modbus_write_cap_from_driver_or_device_config()
|
||||||
|
{
|
||||||
|
var folder = new RawFolder { RawFolderId = "f", ClusterId = "c1", Name = "Plant" };
|
||||||
|
var mb = new DriverInstance { DriverInstanceId = "drv-mb", ClusterId = "c1", RawFolderId = "f", Name = "Mb", DriverType = "Modbus", DriverConfig = "{\"MaxRegistersPerWrite\":100}" };
|
||||||
|
var open = new Device { DeviceId = "d-open", DriverInstanceId = "drv-mb", Name = "open", DeviceConfig = "{}" };
|
||||||
|
var capped = new Device { DeviceId = "d-capped", DriverInstanceId = "drv-mb", Name = "capped", DeviceConfig = "{\"maxRegistersPerWrite\":8}" };
|
||||||
|
static Tag Arr(string id, string device, int length) => new()
|
||||||
|
{
|
||||||
|
TagId = id, DeviceId = device, Name = id, DataType = "Int16", AccessLevel = TagAccessLevel.ReadWrite,
|
||||||
|
TagConfig = $"{{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":{length}}}",
|
||||||
|
};
|
||||||
|
var tags = new[] { Arr("fits", "d-open", 100), Arr("overDriverCap", "d-open", 101), Arr("overDeviceCap", "d-capped", 10) };
|
||||||
|
var area = new UnsArea { UnsAreaId = "a", ClusterId = "c1", Name = "filling" };
|
||||||
|
var line = new UnsLine { UnsLineId = "l", UnsAreaId = "a", Name = "line-1" };
|
||||||
|
var equip = new Equipment { EquipmentId = "e", UnsLineId = "l", Name = "station-1", MachineCode = "S1" };
|
||||||
|
var refs = new[] { new UnsTagReference { UnsTagReferenceId = "r", EquipmentId = "e", TagId = "overDeviceCap" } };
|
||||||
|
|
||||||
|
var c = AddressSpaceComposer.Compose(
|
||||||
|
new[] { area }, new[] { line }, new[] { equip }, new[] { mb }, Array.Empty<ScriptedAlarm>(),
|
||||||
|
unsTagReferences: refs, tags: tags, rawFolders: new[] { folder }, devices: new[] { open, capped });
|
||||||
|
|
||||||
|
c.RawTags.Single(t => t.Name == "fits").ArrayWritable.ShouldBeTrue();
|
||||||
|
c.RawTags.Single(t => t.Name == "overDriverCap").ArrayWritable.ShouldBeFalse();
|
||||||
|
c.RawTags.Single(t => t.Name == "overDeviceCap").ArrayWritable.ShouldBeFalse();
|
||||||
|
c.UnsReferenceVariables.Single().BackingArrayWritable.ShouldBeFalse();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,8 +129,10 @@ public sealed class EquipmentWriteGateTests
|
|||||||
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: 42, node).ShouldBeNull();
|
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: 42, node).ShouldBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>(A3) Numeric-to-numeric is treated as compatible (the SDK widens/narrows numerics) — a
|
/// <summary>(A3) SCALAR numeric-to-numeric is treated as compatible — a Double payload into an Int32 node
|
||||||
/// Double payload into an Int32 node proceeds rather than being rejected here.</summary>
|
/// proceeds rather than being rejected here. Deliberately looser than the SDK (which never sees these writes:
|
||||||
|
/// it skips its type check when OnWriteValue is set): consumers write CLR-typed values, and every driver's
|
||||||
|
/// scalar encoder converts with a range check. Arrays are matched exactly instead (#610 review).</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Structure_numeric_to_numeric_proceeds()
|
public void Structure_numeric_to_numeric_proceeds()
|
||||||
{
|
{
|
||||||
@@ -211,6 +213,54 @@ public sealed class EquipmentWriteGateTests
|
|||||||
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new short[] { 1, 2, 3 }, node).ShouldBeNull();
|
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new short[] { 1, 2, 3 }, node).ShouldBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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>
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(MistypedArrays))]
|
||||||
|
public void Structure_array_node_requires_the_exact_element_type(NodeId dataType, Array matching, Array mistyped)
|
||||||
|
{
|
||||||
|
var node = ArrayNode(dataType, length: 2);
|
||||||
|
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(matching, node).ShouldBeNull();
|
||||||
|
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(mistyped, node)!.StatusCode
|
||||||
|
.ShouldBe((StatusCode)StatusCodes.BadTypeMismatch, mistyped.GetType().Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TheoryData<NodeId, Array, Array> MistypedArrays() => new()
|
||||||
|
{
|
||||||
|
{ DataTypeIds.Int16, new short[] { 1, 2 }, new[] { 1, 2 } }, // Int32[] → Int16
|
||||||
|
{ 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.Boolean, new[] { true, false }, new[] { 1, 0 } }, // Int32[] → Boolean
|
||||||
|
{ DataTypeIds.Int16, new short[] { 1, 2 }, new object[] { (short)1, (short)2 } }, // unclassifiable ⇒ refused
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>A Byte array decodes as <c>byte[]</c>, which classifies as ByteString — the one equivalence the SDK
|
||||||
|
/// itself honours for array nodes.</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
|
||||||
|
.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>
|
||||||
|
[Fact]
|
||||||
|
public void Structure_scalar_node_refuses_an_array_value()
|
||||||
|
{
|
||||||
|
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new short[] { 1 }, ValueNode(DataTypeIds.Int16))!.StatusCode
|
||||||
|
.ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
|
||||||
|
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure((short)1, ValueNode(DataTypeIds.Int16)).ShouldBeNull();
|
||||||
|
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new byte[] { 1 }, ValueNode(DataTypeIds.ByteString)).ShouldBeNull();
|
||||||
|
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new short[] { 1 }, ValueNode(DataTypeIds.BaseDataType)).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>A partial (IndexRange) write is refused; an absent range proceeds.</summary>
|
/// <summary>A partial (IndexRange) write is refused; an absent range proceeds.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Range_only_whole_value_writes_proceed()
|
public void Range_only_whole_value_writes_proceed()
|
||||||
|
|||||||
Reference in New Issue
Block a user