Merge master 4fb04a9f (lane D-a) into remed/lane-D-b
ScriptEdit.razor conflict resolved keeping both D-a's on-demand Monaco sync + Problems list and D-b's delete confirm. Refs #745 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -148,8 +148,10 @@ Two things to know before relying on any of this:
|
||||
`refuseArrayOnScalar` — pass-through drivers keep their browse-committed scalar-shaped array tags writable;
|
||||
a new sink wrapper must forward that argument, and the reflection guard checks only that the call arrives);
|
||||
`IndexRange` writes ⇒ `BadWriteNotSupported`, `ValueRank > 1` out of scope. An
|
||||
array node is writable only when the driver can write the array (`DriverArrayWriteSupport`: Modbus bounded
|
||||
by `maxRegistersPerWrite`/coil cap, S7 non-Bool/non-wide types, OpcUaClient, Galaxy signed types **with a
|
||||
array node is writable only when the driver can write the array (`DriverArrayWriteSupport`: Modbus keys-addressed tags bounded
|
||||
by `maxRegistersPerWrite` (never above FC16's 123)/coil cap/address 65535 incl. String/BitInRegister arrays
|
||||
(BitInRegister also by `maxRegistersPerRead`: its RMW is one unchunked FC03) (#741; a string-only `addressString` tag
|
||||
stays read-only — family unknown at compose time), S7 non-Bool/non-wide types, OpcUaClient, Galaxy signed types **with a
|
||||
declared `arrayLength`** — an undeclared length stays read-only because a short whole-array write would
|
||||
resize the Galaxy attribute; AbCip atomic non-BOOL types, AbLegacy Int/Long/Float on the matching data file
|
||||
(no `/bit`/`.sub`/indirect/`S:`/`I:`/`O:`, not native PLC-5, ≤ 256), TwinCAT every codec type but a BOOL
|
||||
|
||||
+5
-4
@@ -323,7 +323,7 @@ carried on the plan records (`RawTagPlan.ArrayWritable`, mirrored onto every UNS
|
||||
|
||||
| Driver | Array writes | Element types |
|
||||
|---|---|---|
|
||||
| **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 |
|
||||
| **Modbus** | Yes (String / BitInRegister arrays since #741) | Tags addressed by `region` + `address` keys (strict reads: an integer address 0–65535, enum strings that name a member). `region: Coils` (FC15, ≤ 1968 coils) with **any** `dataType` — a bit region is coerced to Bool, never refused. `region: HoldingRegisters` (FC16) with a fixed-width numeric type (`Int16`/`UInt16`/`Bcd16`/`Int32`/`UInt32`/`Bcd32`/`Float32`/`Int64`/`UInt64`/`Float64`), `String` (`(stringLength+1)/2` registers per element, `stringLength >= 1`) or `BitInRegister` (one register per element, read-modify-written in one FC03 + one FC16, `bitIndex` 0–15, and — because of that unchunked FC03 — also within `maxRegistersPerRead`, default and protocol ceiling 125), whose `arrayLength × registers-per-element` fits `maxRegistersPerWrite` (default 123; the smallest value set on the driver or the tag's device wins; a value above FC16's protocol ceiling of 123 never lifts it, since a conformant device refuses a longer FC16) — the driver refuses rather than splits an over-cap FC16, so e.g. a 62-element `Int32` array (124 registers) stays read-only. Every span (registers or coils) must end at or before address 65535. FC23 (opt-in) never changes the answer. Not Bool-in-register; input regions are read-only. A **string-only** tag (`addressString`, no keys) stays read-only: the driver resolves it with its own family, which the composer cannot know — use the editor's Resolve to pin the keys |
|
||||
| **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 (shape included) |
|
||||
| **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 |
|
||||
@@ -340,9 +340,10 @@ stays read-only.
|
||||
**Elements convert; they are not exact-matched.** ScadaBridge writes CLR `int[]` / `List<int>` to Int16
|
||||
nodes: the node manager converts a numeric array to the node's element type, and the AB CIP, AB Legacy
|
||||
and TwinCAT encoders convert each element again to the controller's type (an element that does not fit
|
||||
is `BadOutOfRange`). Those rows therefore do not key on an exact node `DataType`; they leave out only
|
||||
node types whose elements never convert — a `DateTime` or `String` node over a numeric element, and a
|
||||
`Boolean` node on AB Legacy. An array tag deployed read-only before its driver gained an encoder turns
|
||||
is `BadOutOfRange`), as does Modbus to the register / coil type. Those rows therefore do not key on an
|
||||
exact node `DataType`; they leave out only node types whose elements the server cannot promise to convert — a `DateTime` (never converts) or
|
||||
`String` node over a numeric element (converts only when the text happens to parse, `"1"` / `"true"`;
|
||||
on Modbus, over a numeric or bit element; a Modbus `String` element takes any node type), and a `Boolean` node on AB Legacy. An array tag deployed read-only before its driver gained an encoder turns
|
||||
writable on the next deploy (a surgical `ArrayWritable` update, no rebuild).
|
||||
|
||||
Each "Yes" row is pinned against the driver's real encoder by a parity test in that driver's
|
||||
|
||||
+10
-4
@@ -138,11 +138,17 @@ numeric, String, and BitInRegister array reads.
|
||||
**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).
|
||||
|
||||
**Array writes** (#610) — a whole-array write goes out as one FC15 (coils) or one FC16 (numeric
|
||||
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
|
||||
**Array writes** (#610, #741) — a whole-array write goes out as one FC15 (coils) or one FC16
|
||||
(holding registers: numeric, `String` at `(stringLength+1)/2` registers per element, and
|
||||
`BitInRegister` as one FC03 + one FC16 read-modify-write of the span); the driver refuses rather than
|
||||
splits an FC16 longer than `maxRegistersPerWrite` (default 123; a larger value never lifts FC16's
|
||||
protocol ceiling of 123, and a BitInRegister array's unchunked FC03 also bounds it by
|
||||
`maxRegistersPerRead`). The server mirrors that: an array tag
|
||||
is client-writable only when it is addressed by `region` + `address` keys and
|
||||
`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.
|
||||
read-only. A string-only (`addressString`) array stays read-only, because the composer cannot resolve
|
||||
it under the driver's family — see [Uns.md §Array writes](../Uns.md#array-writes-610) and
|
||||
[modbus-addressing.md §Array writes](../v2/modbus-addressing.md#array-writes-610-741).
|
||||
|
||||
**Deferrals** — multi-dimensional arrays (`arrayLength` is always 1-D); per-element historization.
|
||||
|
||||
|
||||
@@ -89,6 +89,13 @@ For 8-byte values (Int64 / Float64) the same labels apply pairwise.
|
||||
`40001:F:5` = `Float32[5]` (consumes HR[0..9]). Array + bit suffix is
|
||||
rejected. Strings are not arrays.
|
||||
|
||||
An array written through the grammar string alone (a **string-only** tag —
|
||||
`addressString` with no `region`/`address` keys) is served and written by the
|
||||
driver, but its OPC UA node stays **read-only**: whether a write fits is
|
||||
decided at compose time, where the driver's family is not known. Pin the keys
|
||||
(the editor's "Resolve with driver family") to make the array writable — see
|
||||
"Array writes" below.
|
||||
|
||||
### Composition
|
||||
|
||||
The 3-field shorthand `40001:F:5` is parsed as `(type=F, count=5)` because
|
||||
@@ -279,3 +286,37 @@ than asking users to rewrite spreadsheets.
|
||||
`BitInRegister`).
|
||||
- Exception 01/02/03 are device refusals: per-tag Bad, one Warning per tag,
|
||||
no driver Degraded; reads and writes share one comms-fault health slot.
|
||||
|
||||
## Array writes (#610, #741)
|
||||
|
||||
Whether an array node is client-writable is decided by
|
||||
`DriverArrayWriteSupport` (Core.Abstractions) at compose time, and it says yes
|
||||
only for what `ModbusDriver.WriteAsync` encodes end to end
|
||||
(`ModbusArrayWriteSupportParityTests` pins the two against each other):
|
||||
|
||||
- **Keys only.** The tag must carry `region` + `address` (read as strictly as
|
||||
the driver reads them). A string-only tag stays read-only — the composer
|
||||
cannot resolve the grammar under the driver's family.
|
||||
- **Coils:** one FC15 of at most 1968 coils (the spec ceiling; the driver does
|
||||
not chunk). Any `dataType` — a bit region is coerced to Bool.
|
||||
- **HoldingRegisters:** one FC16 whose register count fits
|
||||
`maxRegistersPerWrite` (default 123; the smallest value on the driver or the
|
||||
tag's device wins), and never more than FC16's protocol ceiling of 123 — a
|
||||
larger configured cap does not lift it, because a conformant device refuses
|
||||
the longer FC16. Registers per element: 1 / 2 / 4 for the numeric types,
|
||||
`(stringLength+1)/2` for `String` (each element nul-padded to that width), 1
|
||||
for `BitInRegister` (the span is read with one FC03, the addressed bit of each
|
||||
register is set or cleared, and the span is written back with one FC16 —
|
||||
the other 15 bits are preserved; `bitIndex` 0–15). Because that FC03 is one
|
||||
unchunked read of the whole span, a BitInRegister array must also fit
|
||||
`maxRegistersPerRead` (default and protocol ceiling 125).
|
||||
- **Every span** — registers or coils — must end at or before address 65535
|
||||
(a conformant device answers exception 02 past it). FC23 (`useFC23ForMultiRegisterWrites`) is opt-in,
|
||||
capped at 121 registers and falls back to FC16 above that, so it never
|
||||
changes which arrays are writable.
|
||||
- **Never writable:** DiscreteInputs / InputRegisters, Bool in a register
|
||||
region, a `String` with no `stringLength`, a `bitIndex` above 15, a missing
|
||||
or zero `arrayLength`, and a `String` / `DateTime` node over a numeric or bit
|
||||
element (a DateTime never converts; a String converts only when its text
|
||||
happens to parse, which the server cannot promise). Elements otherwise convert, so a
|
||||
ScadaBridge `int[]` to an `Int16` node is writable.
|
||||
|
||||
@@ -47,7 +47,7 @@ public class HistoryReadCommand : CommandBase
|
||||
/// <summary>
|
||||
/// Gets the optional aggregate name used when the operator wants processed history instead of raw samples.
|
||||
/// </summary>
|
||||
[CommandOption("aggregate", Description = "Aggregate function: Average, Minimum, Maximum, Count, Start, End, StandardDeviation, TimeAverage, TimeAverage2, Total, Total2, Interpolative")]
|
||||
[CommandOption("aggregate", Description = "Aggregate function: Average, Minimum, Maximum, Count, Start, End, TimeAverage, TimeAverage2, Total, Total2, Interpolative, Range, Delta, MinimumActualTime, MaximumActualTime, PercentGood, PercentBad")]
|
||||
public string? Aggregate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
@@ -199,24 +199,33 @@ public class HistoryReadCommand : CommandBase
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly string SupportedAggregates = string.Join(", ", Enum.GetNames<AggregateType>());
|
||||
|
||||
private static AggregateType ParseAggregateType(string name)
|
||||
{
|
||||
return name.Trim().ToLowerInvariant() switch
|
||||
var trimmed = name.Trim();
|
||||
var alias = trimmed.ToLowerInvariant() switch
|
||||
{
|
||||
"average" or "avg" => AggregateType.Average,
|
||||
"minimum" or "min" => AggregateType.Minimum,
|
||||
"maximum" or "max" => AggregateType.Maximum,
|
||||
"count" => AggregateType.Count,
|
||||
"start" or "first" => AggregateType.Start,
|
||||
"end" or "last" => AggregateType.End,
|
||||
"standarddeviation" or "stddev" or "stdev" => AggregateType.StandardDeviation,
|
||||
"timeaverage" or "timeavg" => AggregateType.TimeAverage,
|
||||
"timeaverage2" or "timeavg2" => AggregateType.TimeAverage2,
|
||||
"total" => AggregateType.Total,
|
||||
"total2" => AggregateType.Total2,
|
||||
"interpolative" or "interp" => AggregateType.Interpolative,
|
||||
_ => throw new ArgumentException(
|
||||
$"Unknown aggregate: '{name}'. Supported: Average, Minimum, Maximum, Count, Start, End, StandardDeviation, TimeAverage, TimeAverage2, Total, Total2, Interpolative")
|
||||
"avg" => AggregateType.Average,
|
||||
"min" => AggregateType.Minimum,
|
||||
"max" => AggregateType.Maximum,
|
||||
"first" => AggregateType.Start,
|
||||
"last" => AggregateType.End,
|
||||
"timeavg" => AggregateType.TimeAverage,
|
||||
"timeavg2" => AggregateType.TimeAverage2,
|
||||
"interp" => AggregateType.Interpolative,
|
||||
"mintime" => AggregateType.MinimumActualTime,
|
||||
"maxtime" => AggregateType.MaximumActualTime,
|
||||
_ => (AggregateType?)null
|
||||
};
|
||||
if (alias is { } a) return a;
|
||||
|
||||
// Every enum name (the served set; a parity test pins it), case-insensitive. Numeric strings are refused:
|
||||
// Enum.TryParse would accept "6" or "99" as an undefined value.
|
||||
if (!int.TryParse(trimmed, out _) &&
|
||||
Enum.TryParse<AggregateType>(trimmed, ignoreCase: true, out var parsed) && Enum.IsDefined(parsed))
|
||||
return parsed;
|
||||
|
||||
throw new ArgumentException($"Unknown aggregate: '{name}'. Supported: {SupportedAggregates}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,22 +115,6 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
return (nextCp, nextRefs ?? []);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> HasChildrenAsync(NodeId nodeId, CancellationToken ct)
|
||||
{
|
||||
var (_, _, references) = await _session.BrowseAsync(
|
||||
null,
|
||||
null,
|
||||
nodeId,
|
||||
1u,
|
||||
BrowseDirection.Forward,
|
||||
ReferenceTypeIds.HierarchicalReferences,
|
||||
true,
|
||||
0u);
|
||||
|
||||
return references != null && references.Count > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Models.HistoryUpdateOutcome> HistoryUpdateAsync(HistoryUpdateDetails details, CancellationToken ct)
|
||||
{
|
||||
@@ -164,46 +148,16 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
ReturnBounds = false
|
||||
};
|
||||
|
||||
var nodesToRead = new HistoryReadValueIdCollection
|
||||
{
|
||||
new HistoryReadValueId { NodeId = nodeId }
|
||||
};
|
||||
|
||||
var allValues = new List<DataValue>();
|
||||
byte[]? continuationPoint = null;
|
||||
|
||||
do
|
||||
{
|
||||
if (continuationPoint != null)
|
||||
nodesToRead[0].ContinuationPoint = continuationPoint;
|
||||
|
||||
// Use the async overload so this method is genuinely asynchronous,
|
||||
// honors the cancellation token, and does not block the caller's thread
|
||||
// (which would block the UI dispatcher for client.ui consumers).
|
||||
var response = await _session.HistoryReadAsync(
|
||||
null,
|
||||
new ExtensionObject(details),
|
||||
TimestampsToReturn.Source,
|
||||
continuationPoint != null,
|
||||
nodesToRead,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
var results = response.Results;
|
||||
if (results == null || results.Count == 0)
|
||||
break;
|
||||
|
||||
var result = results[0];
|
||||
// A refused read (BadHistoryOperationUnsupported, BadUserAccessDenied, ...) must surface; only
|
||||
// BadNoData is an empty result. Swallowing it printed "0 values returned" for a refusal.
|
||||
HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
|
||||
|
||||
if (result.HistoryData is ExtensionObject ext && ext.Body is HistoryData historyData)
|
||||
allValues.AddRange(historyData.DataValues);
|
||||
|
||||
continuationPoint = result.ContinuationPoint;
|
||||
} while (continuationPoint != null && continuationPoint.Length > 0 && allValues.Count < maxValues);
|
||||
|
||||
return allValues;
|
||||
// Continuation pages are READ, never released, until the server finishes or the cap is reached; a read stopped
|
||||
// by the cap releases the server's cursor (HistoryReadPaging). This path used to pass
|
||||
// `releaseContinuationPoints: continuationPoint != null`, which RELEASED the second page instead of reading it,
|
||||
// silently truncating every raw read to its first server page.
|
||||
return await HistoryReadPaging.ReadAllAsync(
|
||||
(cp, release, token) => ReadHistoryPageAsync(details, nodeId, cp, release, TimestampsToReturn.Source, token),
|
||||
result => result.HistoryData is ExtensionObject { Body: HistoryData historyData }
|
||||
? historyData.DataValues
|
||||
: [],
|
||||
maxValues, nodeId, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -219,59 +173,41 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
ReturnBounds = false
|
||||
};
|
||||
|
||||
return await HistoryReadPaging.ReadAllAsync(
|
||||
(cp, release, token) => ReadHistoryPageAsync(details, nodeId, cp, release, TimestampsToReturn.Both, token),
|
||||
ToModifiedEntries, maxValues, nodeId, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static IEnumerable<Models.HistoryModifiedEntry> ToModifiedEntries(HistoryReadResult result)
|
||||
{
|
||||
if (result.HistoryData is not ExtensionObject { Body: HistoryModifiedData modified }) yield break;
|
||||
for (var i = 0; i < modified.DataValues.Count; i++)
|
||||
{
|
||||
var info = i < modified.ModificationInfos.Count ? modified.ModificationInfos[i] : null;
|
||||
yield return new Models.HistoryModifiedEntry(
|
||||
modified.DataValues[i],
|
||||
info?.ModificationTime ?? DateTime.MinValue,
|
||||
info?.UpdateType ?? default,
|
||||
info?.UserName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Issues one single-node HistoryRead (or a release of its continuation point).</summary>
|
||||
private async Task<HistoryReadResult?> ReadHistoryPageAsync(
|
||||
ReadRawModifiedDetails details, NodeId nodeId, byte[]? continuationPoint, bool release,
|
||||
TimestampsToReturn timestamps, CancellationToken ct)
|
||||
{
|
||||
var nodesToRead = new HistoryReadValueIdCollection
|
||||
{
|
||||
new HistoryReadValueId { NodeId = nodeId }
|
||||
new HistoryReadValueId { NodeId = nodeId, ContinuationPoint = continuationPoint }
|
||||
};
|
||||
|
||||
var all = new List<Models.HistoryModifiedEntry>();
|
||||
byte[]? continuationPoint = null;
|
||||
// The async overload honours the token and does not block the caller's thread (the UI dispatcher for
|
||||
// client.ui consumers).
|
||||
var response = await _session.HistoryReadAsync(
|
||||
null, new ExtensionObject(details), timestamps, release, nodesToRead, ct).ConfigureAwait(false);
|
||||
|
||||
do
|
||||
{
|
||||
if (continuationPoint != null)
|
||||
nodesToRead[0].ContinuationPoint = continuationPoint;
|
||||
|
||||
var response = await _session.HistoryReadAsync(
|
||||
null,
|
||||
new ExtensionObject(details),
|
||||
TimestampsToReturn.Both,
|
||||
false,
|
||||
nodesToRead,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
var results = response.Results;
|
||||
if (results == null || results.Count == 0)
|
||||
break;
|
||||
|
||||
var result = results[0];
|
||||
HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
|
||||
|
||||
if (result.HistoryData is ExtensionObject ext && ext.Body is HistoryModifiedData modified)
|
||||
{
|
||||
for (var i = 0; i < modified.DataValues.Count; i++)
|
||||
{
|
||||
var info = i < modified.ModificationInfos.Count ? modified.ModificationInfos[i] : null;
|
||||
all.Add(new Models.HistoryModifiedEntry(
|
||||
modified.DataValues[i],
|
||||
info?.ModificationTime ?? DateTime.MinValue,
|
||||
info?.UpdateType ?? default,
|
||||
info?.UserName));
|
||||
}
|
||||
}
|
||||
|
||||
continuationPoint = result.ContinuationPoint;
|
||||
} while (continuationPoint != null && continuationPoint.Length > 0 && all.Count < maxValues);
|
||||
|
||||
// A read stopped by the client's own cap leaves a server cursor behind; release it.
|
||||
if (continuationPoint is { Length: > 0 })
|
||||
{
|
||||
nodesToRead[0].ContinuationPoint = continuationPoint;
|
||||
await _session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, true,
|
||||
nodesToRead, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return all;
|
||||
return response.Results is { Count: > 0 } results ? results[0] : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -327,9 +263,22 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
};
|
||||
|
||||
_session.AddSubscription(subscription);
|
||||
await subscription.CreateAsync(ct);
|
||||
try
|
||||
{
|
||||
await subscription.CreateAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// A subscription the server never created must not linger in Session.Subscriptions either.
|
||||
try { await _session.RemoveSubscriptionAsync(subscription, CancellationToken.None); }
|
||||
catch (Exception ex) { Logger.Warning(ex, "Error removing a subscription that failed to create"); }
|
||||
throw;
|
||||
}
|
||||
|
||||
return new DefaultSubscriptionAdapter(subscription);
|
||||
// Deleting the adapter also removes the subscription from the SDK session, so a re-targeted alarm/data
|
||||
// subscription does not accumulate dead Subscription objects in Session.Subscriptions.
|
||||
return new DefaultSubscriptionAdapter(subscription,
|
||||
async (s, token) => await _session.RemoveSubscriptionAsync(s, token).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -12,14 +12,23 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
|
||||
private static readonly ILogger Logger = Log.ForContext<DefaultSubscriptionAdapter>();
|
||||
private readonly Dictionary<uint, MonitoredItem> _monitoredItems = new();
|
||||
private readonly Subscription _subscription;
|
||||
private readonly Func<Subscription, CancellationToken, Task>? _detach;
|
||||
private int _detached;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a live OPC UA subscription so client code can manage monitored items through a testable abstraction.
|
||||
/// </summary>
|
||||
/// <param name="subscription">The underlying OPC UA subscription that owns monitored items for this client workflow.</param>
|
||||
public DefaultSubscriptionAdapter(Subscription subscription)
|
||||
/// <param name="detach">
|
||||
/// Removes the subscription from its owning SDK <c>Session</c> once it is deleted. Without it a deleted
|
||||
/// subscription stays in <c>Session.Subscriptions</c> until the session is disposed, so a UI that re-targets
|
||||
/// alarms repeatedly accumulates dead subscriptions the SDK still iterates (transfer / recreate on reconnect).
|
||||
/// </param>
|
||||
public DefaultSubscriptionAdapter(Subscription subscription,
|
||||
Func<Subscription, CancellationToken, Task>? detach = null)
|
||||
{
|
||||
_subscription = subscription;
|
||||
_detach = detach;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -113,17 +122,39 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
|
||||
Logger.Warning(ex, "Error deleting subscription");
|
||||
}
|
||||
|
||||
// Detach whether or not the server-side delete succeeded: the adapter is finished with it either way.
|
||||
await DetachAsync(ct);
|
||||
_monitoredItems.Clear();
|
||||
}
|
||||
|
||||
private async Task DetachAsync(CancellationToken ct)
|
||||
{
|
||||
if (_detach == null || Interlocked.Exchange(ref _detached, 1) != 0) return;
|
||||
try
|
||||
{
|
||||
await _detach(_subscription, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning(ex, "Error removing subscription from its session");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases the wrapped OPC UA subscription and clears tracked monitored items held by the adapter.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
// One bounded bridge (5 s) for delete + detach, so a dispose never blocks longer than before (review m6).
|
||||
// DetachAsync swallows its own failures; a failed delete must not skip the detach.
|
||||
try
|
||||
{
|
||||
DisposeBridge.Run(ct => _subscription.DeleteAsync(true, ct));
|
||||
DisposeBridge.Run(async ct =>
|
||||
{
|
||||
try { await _subscription.DeleteAsync(true, ct); }
|
||||
catch (Exception ex) { Logger.Warning(ex, "Error deleting subscription"); }
|
||||
await DetachAsync(ct);
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -90,14 +90,6 @@ internal interface ISessionAdapter : IDisposable
|
||||
Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
|
||||
byte[] continuationPoint, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a node has any forward hierarchical child references.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The node to inspect for child objects or variables.</param>
|
||||
/// <param name="ct">The cancellation token that aborts the child lookup.</param>
|
||||
/// <returns><c>true</c> when the node has at least one forward hierarchical child reference; otherwise <c>false</c>.</returns>
|
||||
Task<bool> HasChildrenAsync(NodeId nodeId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Reads raw historical data.
|
||||
/// </summary>
|
||||
|
||||
@@ -23,12 +23,17 @@ public static class AggregateTypeMapper
|
||||
AggregateType.Count => ObjectIds.AggregateFunction_Count,
|
||||
AggregateType.Start => ObjectIds.AggregateFunction_Start,
|
||||
AggregateType.End => ObjectIds.AggregateFunction_End,
|
||||
AggregateType.StandardDeviation => ObjectIds.AggregateFunction_StandardDeviationPopulation,
|
||||
AggregateType.TimeAverage => ObjectIds.AggregateFunction_TimeAverage,
|
||||
AggregateType.TimeAverage2 => ObjectIds.AggregateFunction_TimeAverage2,
|
||||
AggregateType.Total => ObjectIds.AggregateFunction_Total,
|
||||
AggregateType.Total2 => ObjectIds.AggregateFunction_Total2,
|
||||
AggregateType.Interpolative => ObjectIds.AggregateFunction_Interpolative,
|
||||
AggregateType.Range => ObjectIds.AggregateFunction_Range,
|
||||
AggregateType.Delta => ObjectIds.AggregateFunction_Delta,
|
||||
AggregateType.MinimumActualTime => ObjectIds.AggregateFunction_MinimumActualTime,
|
||||
AggregateType.MaximumActualTime => ObjectIds.AggregateFunction_MaximumActualTime,
|
||||
AggregateType.PercentGood => ObjectIds.AggregateFunction_PercentGood,
|
||||
AggregateType.PercentBad => ObjectIds.AggregateFunction_PercentBad,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, "Unknown AggregateType value.")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using Opc.Ua;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// The client-side HistoryRead paging loop shared by the raw and modified reads. Continuation pages are
|
||||
/// READ (<c>releaseContinuationPoints = false</c>) while the caller still wants values; a read stopped by the
|
||||
/// caller's own cap while the server still holds a cursor releases that cursor so it does not occupy one of the
|
||||
/// session's continuation-point slots until eviction.
|
||||
/// </summary>
|
||||
internal static class HistoryReadPaging
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext(typeof(HistoryReadPaging));
|
||||
|
||||
/// <summary>Reads (or, with <paramref name="releaseContinuationPoints"/>, releases) one page.</summary>
|
||||
/// <param name="continuationPoint">The cursor to resume, or <see langword="null"/> for the first page.</param>
|
||||
/// <param name="releaseContinuationPoints"><see langword="true"/> to release the cursor instead of reading.</param>
|
||||
/// <param name="ct">The caller's cancellation token.</param>
|
||||
/// <returns>The per-node result, or <see langword="null"/> when the response carried none.</returns>
|
||||
public delegate Task<HistoryReadResult?> PageReader(
|
||||
byte[]? continuationPoint, bool releaseContinuationPoints, CancellationToken ct);
|
||||
|
||||
/// <summary>The bound on a best-effort release that does not ride the caller's (possibly cancelled) token.</summary>
|
||||
internal static readonly TimeSpan ReleaseTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>Reads pages until the server finishes or the caller's cap is reached.</summary>
|
||||
/// <typeparam name="T">The element type extracted from each page.</typeparam>
|
||||
/// <param name="readPage">Issues one HistoryRead for the single node.</param>
|
||||
/// <param name="extract">Extracts the page's elements from a result.</param>
|
||||
/// <param name="maxValues">The caller's cap; must be positive. The result is trimmed to it.</param>
|
||||
/// <param name="nodeId">The node being read, for refusal messages.</param>
|
||||
/// <param name="ct">The caller's cancellation token.</param>
|
||||
/// <returns>At most <paramref name="maxValues"/> elements.</returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException"><paramref name="maxValues"/> is not positive: an uncapped client
|
||||
/// read would materialise a tag's whole window (the client side of review 08 1.1).</exception>
|
||||
/// <exception cref="ServiceResultException">A page was refused (see <see cref="HistoryReadStatus"/>).</exception>
|
||||
/// <exception cref="OperationCanceledException"><paramref name="ct"/> was cancelled mid-read; the outstanding
|
||||
/// cursor is released (best effort) first.</exception>
|
||||
public static async Task<List<T>> ReadAllAsync<T>(
|
||||
PageReader readPage, Func<HistoryReadResult, IEnumerable<T>> extract, int maxValues, NodeId nodeId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxValues);
|
||||
|
||||
var all = new List<T>();
|
||||
byte[]? continuationPoint = null;
|
||||
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var result = await readPage(continuationPoint, false, ct).ConfigureAwait(false);
|
||||
if (result == null) return Trim(all, maxValues);
|
||||
|
||||
HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
|
||||
all.AddRange(extract(result));
|
||||
|
||||
continuationPoint = result.ContinuationPoint;
|
||||
if (continuationPoint is not { Length: > 0 }) return Trim(all, maxValues);
|
||||
if (all.Count >= maxValues) break;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Cancelled between pages (or during page N+1) with a cursor outstanding: release it rather than leave it
|
||||
// occupying one of the session's continuation-point slots, then surface the cancellation.
|
||||
if (continuationPoint is { Length: > 0 }) await ReleaseAsync(readPage, continuationPoint, nodeId);
|
||||
throw;
|
||||
}
|
||||
|
||||
// Stopped by the client's own cap: the server still holds a cursor for this session. Release it — on its own
|
||||
// bounded token, so a cancellation racing the release cannot lose the values already in hand.
|
||||
await ReleaseAsync(readPage, continuationPoint, nodeId);
|
||||
return Trim(all, maxValues);
|
||||
}
|
||||
|
||||
private static List<T> Trim<T>(List<T> all, int maxValues)
|
||||
{
|
||||
if (all.Count > maxValues) all.RemoveRange(maxValues, all.Count - maxValues);
|
||||
return all;
|
||||
}
|
||||
|
||||
private static async Task ReleaseAsync(PageReader readPage, byte[] continuationPoint, NodeId nodeId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(ReleaseTimeout);
|
||||
await readPage(continuationPoint, true, cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning(ex, "Releasing the HistoryRead continuation point for {NodeId} failed", nodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,64 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate functions for processed history reads.
|
||||
/// Aggregate functions for processed history reads — exactly the seventeen the server's SQL historian backends
|
||||
/// serve (<c>AggregateFamilies.Served</c>; a parity test pins it). The Gateway backend serves a subset and refuses
|
||||
/// the rest with <c>BadAggregateNotSupported</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Values are explicit so the pre-existing members keep their numbers. 6 (the former <c>StandardDeviation</c>,
|
||||
/// which no backend serves) is retired; do not reuse it.
|
||||
/// </remarks>
|
||||
public enum AggregateType
|
||||
{
|
||||
/// <summary>Average of values in the interval.</summary>
|
||||
Average,
|
||||
Average = 0,
|
||||
|
||||
/// <summary>Minimum value in the interval.</summary>
|
||||
Minimum,
|
||||
Minimum = 1,
|
||||
|
||||
/// <summary>Maximum value in the interval.</summary>
|
||||
Maximum,
|
||||
Maximum = 2,
|
||||
|
||||
/// <summary>Count of values in the interval.</summary>
|
||||
Count,
|
||||
Count = 3,
|
||||
|
||||
/// <summary>First value in the interval.</summary>
|
||||
Start,
|
||||
Start = 4,
|
||||
|
||||
/// <summary>Last value in the interval.</summary>
|
||||
End,
|
||||
|
||||
/// <summary>Population standard deviation of values in the interval.</summary>
|
||||
StandardDeviation,
|
||||
End = 5,
|
||||
|
||||
/// <summary>Time-weighted average, interpolated bounds (Part 13 TimeAverage).</summary>
|
||||
TimeAverage,
|
||||
TimeAverage = 7,
|
||||
|
||||
/// <summary>Time-weighted average, stepped bounds (Part 13 TimeAverage2).</summary>
|
||||
TimeAverage2,
|
||||
TimeAverage2 = 8,
|
||||
|
||||
/// <summary>Time-weighted integral over the interval (Part 13 Total).</summary>
|
||||
Total,
|
||||
Total = 9,
|
||||
|
||||
/// <summary>Time-weighted integral, stepped bounds (Part 13 Total2).</summary>
|
||||
Total2,
|
||||
Total2 = 10,
|
||||
|
||||
/// <summary>Interpolated value at the interval start (Part 13 Interpolative).</summary>
|
||||
Interpolative
|
||||
}
|
||||
Interpolative = 11,
|
||||
|
||||
/// <summary>Maximum minus minimum in the interval (Part 13 Range).</summary>
|
||||
Range = 12,
|
||||
|
||||
/// <summary>Last minus first value in the interval (Part 13 Delta).</summary>
|
||||
Delta = 13,
|
||||
|
||||
/// <summary>Minimum value with its own timestamp (Part 13 MinimumActualTime).</summary>
|
||||
MinimumActualTime = 14,
|
||||
|
||||
/// <summary>Maximum value with its own timestamp (Part 13 MaximumActualTime).</summary>
|
||||
MaximumActualTime = 15,
|
||||
|
||||
/// <summary>Percentage of the interval with Good data (Part 13 PercentGood).</summary>
|
||||
PercentGood = 16,
|
||||
|
||||
/// <summary>Percentage of the interval with Bad data (Part 13 PercentBad).</summary>
|
||||
PercentBad = 17
|
||||
}
|
||||
|
||||
@@ -34,7 +34,9 @@ public sealed class BrowseResult
|
||||
public string NodeClass { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the node has child references.
|
||||
/// Whether the node MAY have child references: <see langword="true"/> for every Object, confirmed lazily when the
|
||||
/// caller browses it (an expanded empty folder simply yields no children). Variables and Methods report
|
||||
/// <see langword="false"/>.
|
||||
/// </summary>
|
||||
public bool HasChildren { get; }
|
||||
}
|
||||
@@ -15,16 +15,17 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext<OpcUaClientService>();
|
||||
|
||||
// Guards all access to the subscription-bookkeeping state below
|
||||
// (_activeDataSubscriptions and _activeAlarmSubscription). The dictionary
|
||||
// and tuple are mutated from the caller thread, the keep-alive failover
|
||||
// path, and DisconnectAsync, so every read/write must be inside this lock.
|
||||
// Guards all access to the subscription state below (_activeDataSubscriptions, _activeAlarmSubscription,
|
||||
// _dataSubscription, _alarmSubscription). They are touched from the caller thread, the keep-alive failover path
|
||||
// and DisconnectAsync, so every read/write is inside this lock — including failover's null-outs.
|
||||
private readonly object _subscriptionLock = new();
|
||||
|
||||
// Serialises SubscribeAlarmsAsync / UnsubscribeAlarmsAsync so concurrent callers
|
||||
// cannot both pass the _alarmSubscription == null check and create duplicate event
|
||||
// subscriptions. Capacity 1 = at most one waiter; async-friendly (no thread-block).
|
||||
private readonly SemaphoreSlim _alarmSubscribeSemaphore = new(1, 1);
|
||||
// Serialises every subscription operation that awaits I/O (Subscribe/Unsubscribe, SubscribeAlarms/
|
||||
// UnsubscribeAlarms) AND the failover's teardown + reconnect + replay. A subscribe is therefore either
|
||||
// wholly before a failover (recorded, then replayed onto the new session) or wholly after it (on the new
|
||||
// session) — never half on the dying session. Async-friendly (no thread block). Deliberately never disposed:
|
||||
// async-only waits hold no kernel handle, and a failover still running at Dispose releases it afterwards.
|
||||
private readonly SemaphoreSlim _subscriptionGate = new(1, 1);
|
||||
|
||||
// Track active data subscriptions for replay after failover
|
||||
private readonly Dictionary<string, (NodeId NodeId, int IntervalMs, uint Handle)> _activeDataSubscriptions = new();
|
||||
@@ -34,6 +35,15 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
// run at a time. 0 = idle, 1 = failover in progress (Interlocked-managed).
|
||||
private int _failoverInProgress;
|
||||
|
||||
// The connection epoch (review I1): ConnectAsync and DisconnectAsync each start a new one (under
|
||||
// _subscriptionLock). A failover runs under the epoch it started in and abandons itself — disposing any session it
|
||||
// opened, making no state transition — the moment the epoch moves, so a user Disconnect + Connect during a
|
||||
// failover is never undone by it. A keep-alive from a previous connection's session cannot start one either.
|
||||
private long _connectionEpoch;
|
||||
|
||||
/// <summary>The most recent keep-alive-triggered failover task (test seam: lets a test await it).</summary>
|
||||
internal Task? LastFailover { get; private set; }
|
||||
|
||||
private readonly IApplicationConfigurationFactory _configFactory;
|
||||
private readonly IEndpointDiscovery _endpointDiscovery;
|
||||
|
||||
@@ -99,6 +109,15 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
ThrowIfDisposed();
|
||||
settings.Validate();
|
||||
|
||||
// Overwriting a live (or reconnecting) session would leak it and orphan its subscriptions and keep-alive
|
||||
// handler. The caller must disconnect first.
|
||||
if (_state != ConnectionState.Disconnected || _session != null)
|
||||
throw new InvalidOperationException(
|
||||
$"Already {_state.ToString().ToLowerInvariant()}; call DisconnectAsync before connecting again.");
|
||||
|
||||
long epoch;
|
||||
lock (_subscriptionLock) epoch = ++_connectionEpoch;
|
||||
|
||||
_settings = settings;
|
||||
_allEndpointUrls = FailoverUrlParser.Parse(settings.EndpointUrl, settings.FailoverUrls);
|
||||
_currentEndpointIndex = 0;
|
||||
@@ -108,11 +127,11 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
try
|
||||
{
|
||||
var session = await ConnectToEndpointAsync(settings, _allEndpointUrls[0], ct);
|
||||
_session = session;
|
||||
lock (_subscriptionLock) _session = session;
|
||||
|
||||
session.RegisterKeepAliveHandler(isGood =>
|
||||
{
|
||||
if (!isGood) _ = HandleKeepAliveFailureAsync();
|
||||
if (!isGood) OnKeepAliveFailed(epoch);
|
||||
});
|
||||
|
||||
CurrentConnectionInfo = BuildConnectionInfo(session);
|
||||
@@ -133,6 +152,9 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
if (_state == ConnectionState.Disconnected)
|
||||
return;
|
||||
|
||||
// A new epoch first: a failover still connecting abandons itself instead of resurrecting the connection.
|
||||
lock (_subscriptionLock) _connectionEpoch++;
|
||||
|
||||
var endpointUrl = _session?.EndpointUrl ?? _settings?.EndpointUrl ?? string.Empty;
|
||||
|
||||
// Snapshot the subscription adapter references under the lock so a concurrent
|
||||
@@ -224,18 +246,15 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
|
||||
while (references.Count > 0)
|
||||
{
|
||||
// Child detection is lazy: an Object MAY have children and is confirmed when the caller expands or
|
||||
// recurses into it. Probing each Object child with its own Browse made one level of N folders cost N + 1
|
||||
// round trips, and a recursive subscribe over a 50k-node tree ~50k extra Browse calls.
|
||||
foreach (var reference in references)
|
||||
{
|
||||
var childNodeId = ExpandedNodeId.ToNodeId(reference.NodeId, _session.NamespaceUris);
|
||||
var hasChildren = reference.NodeClass == NodeClass.Object &&
|
||||
await _session.HasChildrenAsync(childNodeId, ct);
|
||||
|
||||
results.Add(new BrowseResult(
|
||||
reference.NodeId.ToString(),
|
||||
reference.DisplayName?.Text ?? string.Empty,
|
||||
reference.NodeClass.ToString(),
|
||||
hasChildren));
|
||||
}
|
||||
reference.NodeClass == NodeClass.Object));
|
||||
|
||||
if (continuationPoint is not { Length: > 0 } pending) break;
|
||||
(continuationPoint, references) = await _session.BrowseNextAsync(pending, ct);
|
||||
@@ -251,20 +270,39 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
ThrowIfNotConnected();
|
||||
|
||||
var nodeIdStr = nodeId.ToString();
|
||||
lock (_subscriptionLock)
|
||||
await _subscriptionGate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (_activeDataSubscriptions.ContainsKey(nodeIdStr))
|
||||
return; // Already subscribed
|
||||
// A failover may have run (and failed) while this call waited for the gate.
|
||||
ThrowIfNotConnected();
|
||||
|
||||
ISubscriptionAdapter? subscription;
|
||||
ISessionAdapter session;
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
if (_activeDataSubscriptions.ContainsKey(nodeIdStr))
|
||||
return; // Already subscribed
|
||||
subscription = _dataSubscription;
|
||||
session = _session!;
|
||||
}
|
||||
|
||||
if (subscription == null)
|
||||
{
|
||||
subscription = await session.CreateSubscriptionAsync(intervalMs, ct);
|
||||
lock (_subscriptionLock) _dataSubscription = subscription;
|
||||
}
|
||||
|
||||
var handle = await subscription.AddDataChangeMonitoredItemAsync(
|
||||
nodeId, intervalMs, OnDataChangeNotification, ct);
|
||||
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
_activeDataSubscriptions[nodeIdStr] = (nodeId, intervalMs, handle);
|
||||
}
|
||||
}
|
||||
|
||||
if (_dataSubscription == null) _dataSubscription = await _session!.CreateSubscriptionAsync(intervalMs, ct);
|
||||
|
||||
var handle = await _dataSubscription.AddDataChangeMonitoredItemAsync(
|
||||
nodeId, intervalMs, OnDataChangeNotification, ct);
|
||||
|
||||
lock (_subscriptionLock)
|
||||
finally
|
||||
{
|
||||
_activeDataSubscriptions[nodeIdStr] = (nodeId, intervalMs, handle);
|
||||
_subscriptionGate.Release();
|
||||
}
|
||||
|
||||
Logger.Debug("Subscribed to data changes on {NodeId}", nodeId);
|
||||
@@ -276,18 +314,35 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
ThrowIfDisposed();
|
||||
|
||||
var nodeIdStr = nodeId.ToString();
|
||||
(NodeId NodeId, int IntervalMs, uint Handle) sub;
|
||||
lock (_subscriptionLock)
|
||||
await _subscriptionGate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (!_activeDataSubscriptions.TryGetValue(nodeIdStr, out sub))
|
||||
return; // Not subscribed, safe to ignore
|
||||
(NodeId NodeId, int IntervalMs, uint Handle) sub;
|
||||
ISubscriptionAdapter? subscription;
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
if (!_activeDataSubscriptions.TryGetValue(nodeIdStr, out sub))
|
||||
return; // Not subscribed, safe to ignore
|
||||
subscription = _dataSubscription;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (subscription != null) await subscription.RemoveMonitoredItemAsync(sub.Handle, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// The caller asked to stop: drop the bookkeeping even when the server-side remove failed (a session
|
||||
// that died under us), or a later failover would replay the item onto the new session (review m5).
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
_activeDataSubscriptions.Remove(nodeIdStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_dataSubscription != null) await _dataSubscription.RemoveMonitoredItemAsync(sub.Handle, ct);
|
||||
|
||||
lock (_subscriptionLock)
|
||||
finally
|
||||
{
|
||||
_activeDataSubscriptions.Remove(nodeIdStr);
|
||||
_subscriptionGate.Release();
|
||||
}
|
||||
|
||||
Logger.Debug("Unsubscribed from data changes on {NodeId}", nodeId);
|
||||
@@ -303,17 +358,25 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
// Serialise check-and-create under the semaphore: concurrent
|
||||
// callers cannot both pass the null-check and create duplicate alarm subscriptions.
|
||||
// The semaphore is async-friendly; the subscription I/O runs inside the critical section.
|
||||
await _alarmSubscribeSemaphore.WaitAsync(ct);
|
||||
await _subscriptionGate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (_alarmSubscription != null)
|
||||
return; // Already subscribed to alarms
|
||||
ThrowIfNotConnected();
|
||||
|
||||
ISessionAdapter session;
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
if (_alarmSubscription != null)
|
||||
return; // Already subscribed to alarms
|
||||
session = _session!;
|
||||
}
|
||||
|
||||
var monitorNode = sourceNodeId ?? ObjectIds.Server;
|
||||
_alarmSubscription = await _session!.CreateSubscriptionAsync(intervalMs, ct);
|
||||
var subscription = await session.CreateSubscriptionAsync(intervalMs, ct);
|
||||
lock (_subscriptionLock) _alarmSubscription = subscription;
|
||||
|
||||
var filter = CreateAlarmEventFilter();
|
||||
await _alarmSubscription.AddEventMonitoredItemAsync(
|
||||
await subscription.AddEventMonitoredItemAsync(
|
||||
monitorNode, intervalMs, filter, OnAlarmEventNotification, ct);
|
||||
|
||||
lock (_subscriptionLock)
|
||||
@@ -325,7 +388,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
}
|
||||
finally
|
||||
{
|
||||
_alarmSubscribeSemaphore.Release();
|
||||
_subscriptionGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,15 +397,27 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (_alarmSubscription == null)
|
||||
return;
|
||||
|
||||
await _alarmSubscription.DeleteAsync(ct);
|
||||
_alarmSubscription = null;
|
||||
|
||||
lock (_subscriptionLock)
|
||||
await _subscriptionGate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
_activeAlarmSubscription = null;
|
||||
ISubscriptionAdapter? subscription;
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
subscription = _alarmSubscription;
|
||||
if (subscription == null) return;
|
||||
}
|
||||
|
||||
await subscription.DeleteAsync(ct);
|
||||
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
_alarmSubscription = null;
|
||||
_activeAlarmSubscription = null;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_subscriptionGate.Release();
|
||||
}
|
||||
|
||||
Logger.Debug("Unsubscribed from alarm events");
|
||||
@@ -354,10 +429,12 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotConnected();
|
||||
|
||||
if (_alarmSubscription == null)
|
||||
ISubscriptionAdapter? subscription;
|
||||
lock (_subscriptionLock) subscription = _alarmSubscription;
|
||||
if (subscription == null)
|
||||
throw new InvalidOperationException("No alarm subscription is active.");
|
||||
|
||||
await _alarmSubscription.ConditionRefreshAsync(ct);
|
||||
await subscription.ConditionRefreshAsync(ct);
|
||||
Logger.Debug("Condition refresh requested");
|
||||
}
|
||||
|
||||
@@ -640,13 +717,16 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_connectionEpoch++; // a failover still running abandons itself
|
||||
}
|
||||
|
||||
_dataSubscription?.Dispose();
|
||||
_alarmSubscription?.Dispose();
|
||||
_session?.Dispose();
|
||||
_alarmSubscribeSemaphore.Dispose();
|
||||
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
@@ -688,19 +768,27 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
ct);
|
||||
}
|
||||
|
||||
private async Task HandleKeepAliveFailureAsync()
|
||||
/// <summary>A bad keep-alive from the session of connection <paramref name="epoch"/>.</summary>
|
||||
private void OnKeepAliveFailed(long epoch)
|
||||
{
|
||||
// Serialize failover: the OPC UA stack raises KeepAlive repeatedly
|
||||
// while a session is down, so multiple bad keep-alives can fire before
|
||||
// the first failover loop finishes. CompareExchange atomically claims
|
||||
// the failover slot; a re-entrant call sees 1 and returns immediately,
|
||||
// guaranteeing exactly one failover loop runs at a time.
|
||||
// A late keep-alive from a previous connection's session is not about the current one.
|
||||
if (!IsCurrentEpoch(epoch)) return;
|
||||
|
||||
// Serialize failover: the OPC UA stack raises KeepAlive repeatedly while a session is down, so several bad
|
||||
// keep-alives can fire before the first failover loop finishes. CompareExchange claims the failover slot; a
|
||||
// re-entrant call sees 1 and returns, so exactly one loop runs — and only the winner sets the test seam
|
||||
// (review m8: a re-entrant call used to overwrite it with an already-completed no-op).
|
||||
if (Interlocked.CompareExchange(ref _failoverInProgress, 1, 0) != 0)
|
||||
return;
|
||||
|
||||
LastFailover = HandleKeepAliveFailureAsync(epoch);
|
||||
}
|
||||
|
||||
private async Task HandleKeepAliveFailureAsync(long epoch)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RunFailoverAsync();
|
||||
await RunFailoverAsync(epoch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -708,74 +796,136 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunFailoverAsync()
|
||||
private bool IsCurrentEpoch(long epoch)
|
||||
{
|
||||
lock (_subscriptionLock) return _connectionEpoch == epoch && !_disposed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A state transition made only while <paramref name="epoch"/> is still current (checked and applied under
|
||||
/// <see cref="_subscriptionLock"/>, the event raised outside it).
|
||||
/// </summary>
|
||||
/// <returns><see langword="false"/> when the epoch has moved on (the caller must abandon).</returns>
|
||||
private bool TransitionStateIfCurrent(long epoch, ConnectionState newState, string endpointUrl)
|
||||
{
|
||||
ConnectionState oldState;
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
if (_connectionEpoch != epoch || _disposed) return false;
|
||||
oldState = _state;
|
||||
if (oldState == newState) return true;
|
||||
_state = newState;
|
||||
}
|
||||
|
||||
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(oldState, newState, endpointUrl));
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task RunFailoverAsync(long epoch)
|
||||
{
|
||||
if (_state == ConnectionState.Reconnecting || _state == ConnectionState.Disconnected)
|
||||
return;
|
||||
|
||||
var oldEndpoint = _session?.EndpointUrl ?? string.Empty;
|
||||
TransitionState(ConnectionState.Reconnecting, oldEndpoint);
|
||||
var oldSession = _session;
|
||||
var oldEndpoint = oldSession?.EndpointUrl ?? string.Empty;
|
||||
if (!TransitionStateIfCurrent(epoch, ConnectionState.Reconnecting, oldEndpoint)) return;
|
||||
Logger.Warning("Session lost on {EndpointUrl}. Attempting failover...", oldEndpoint);
|
||||
|
||||
// Close old session
|
||||
if (_session != null)
|
||||
// Dispose the dead session BEFORE taking the gate: an in-flight subscribe holding the gate is blocked on
|
||||
// that session, and disposing it is what makes the call fail fast and release the gate.
|
||||
if (oldSession != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_session.Dispose();
|
||||
oldSession.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
_session = null;
|
||||
}
|
||||
|
||||
_dataSubscription = null;
|
||||
_alarmSubscription = null;
|
||||
|
||||
if (_settings == null || _allEndpointUrls == null)
|
||||
// The rest runs under the subscription gate (and its null-outs under _subscriptionLock): no subscribe can
|
||||
// interleave with the teardown, the reconnect or the replay.
|
||||
await _subscriptionGate.WaitAsync();
|
||||
try
|
||||
{
|
||||
TransitionState(ConnectionState.Disconnected, oldEndpoint);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try each endpoint
|
||||
for (var attempt = 0; attempt < _allEndpointUrls.Length; attempt++)
|
||||
{
|
||||
_currentEndpointIndex = (_currentEndpointIndex + 1) % _allEndpointUrls.Length;
|
||||
var url = _allEndpointUrls[_currentEndpointIndex];
|
||||
|
||||
try
|
||||
// The user's settings / endpoint list as of THIS epoch: a Connect during the failover replaces the fields.
|
||||
ConnectionSettings? settings;
|
||||
string[]? endpoints;
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
Logger.Information("Failover attempt to {EndpointUrl}", url);
|
||||
var session = await ConnectToEndpointAsync(_settings, url, CancellationToken.None);
|
||||
_session = session;
|
||||
if (_connectionEpoch != epoch || _disposed) return; // the user disconnected / reconnected meanwhile
|
||||
if (ReferenceEquals(_session, oldSession)) _session = null;
|
||||
_dataSubscription = null;
|
||||
_alarmSubscription = null;
|
||||
settings = _settings;
|
||||
endpoints = _allEndpointUrls;
|
||||
}
|
||||
|
||||
session.RegisterKeepAliveHandler(isGood =>
|
||||
{
|
||||
if (!isGood) _ = HandleKeepAliveFailureAsync();
|
||||
});
|
||||
|
||||
CurrentConnectionInfo = BuildConnectionInfo(session);
|
||||
TransitionState(ConnectionState.Connected, url);
|
||||
Logger.Information("Failover succeeded to {EndpointUrl}", url);
|
||||
|
||||
// Replay subscriptions
|
||||
await ReplaySubscriptionsAsync();
|
||||
if (settings == null || endpoints == null)
|
||||
{
|
||||
TransitionStateIfCurrent(epoch, ConnectionState.Disconnected, oldEndpoint);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning(ex, "Failover to {EndpointUrl} failed", url);
|
||||
}
|
||||
}
|
||||
|
||||
Logger.Error("All failover endpoints unreachable");
|
||||
TransitionState(ConnectionState.Disconnected, oldEndpoint);
|
||||
// Try each endpoint
|
||||
for (var attempt = 0; attempt < endpoints.Length; attempt++)
|
||||
{
|
||||
if (!IsCurrentEpoch(epoch)) return; // stop trying once the user has moved on (m5)
|
||||
_currentEndpointIndex = (_currentEndpointIndex + 1) % endpoints.Length;
|
||||
var url = endpoints[_currentEndpointIndex];
|
||||
|
||||
try
|
||||
{
|
||||
Logger.Information("Failover attempt to {EndpointUrl}", url);
|
||||
var session = await ConnectToEndpointAsync(settings, url, CancellationToken.None);
|
||||
|
||||
// A Disconnect / Connect / Dispose that ran while this attempt was connecting wins: never
|
||||
// resurrect the old connection or overwrite the user's new session. Checked and installed
|
||||
// atomically against the epoch.
|
||||
bool installed;
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
installed = _connectionEpoch == epoch && !_disposed;
|
||||
if (installed) _session = session;
|
||||
}
|
||||
|
||||
if (!installed)
|
||||
{
|
||||
session.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
session.RegisterKeepAliveHandler(isGood =>
|
||||
{
|
||||
if (!isGood) OnKeepAliveFailed(epoch);
|
||||
});
|
||||
|
||||
CurrentConnectionInfo = BuildConnectionInfo(session);
|
||||
if (!TransitionStateIfCurrent(epoch, ConnectionState.Connected, url)) return;
|
||||
Logger.Information("Failover succeeded to {EndpointUrl}", url);
|
||||
|
||||
// Replay subscriptions
|
||||
await ReplaySubscriptionsAsync(session);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning(ex, "Failover to {EndpointUrl} failed", url);
|
||||
}
|
||||
}
|
||||
|
||||
if (TransitionStateIfCurrent(epoch, ConnectionState.Disconnected, oldEndpoint))
|
||||
Logger.Error("All failover endpoints unreachable");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_subscriptionGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReplaySubscriptionsAsync()
|
||||
/// <summary>Re-creates the recorded subscriptions on <paramref name="session"/>. Runs under the subscription gate.</summary>
|
||||
private async Task ReplaySubscriptionsAsync(ISessionAdapter session)
|
||||
{
|
||||
// Snapshot the bookkeeping state under the lock, then clear it so the
|
||||
// replayed handles can be recorded fresh as each monitored item is
|
||||
@@ -791,13 +941,17 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
}
|
||||
|
||||
// Replay data subscriptions
|
||||
ISubscriptionAdapter? dataSubscription = null;
|
||||
foreach (var (nodeIdStr, (nodeId, intervalMs, _)) in subscriptions)
|
||||
try
|
||||
{
|
||||
if (_dataSubscription == null)
|
||||
_dataSubscription = await _session!.CreateSubscriptionAsync(intervalMs, CancellationToken.None);
|
||||
if (dataSubscription == null)
|
||||
{
|
||||
dataSubscription = await session.CreateSubscriptionAsync(intervalMs, CancellationToken.None);
|
||||
lock (_subscriptionLock) _dataSubscription = dataSubscription;
|
||||
}
|
||||
|
||||
var handle = await _dataSubscription.AddDataChangeMonitoredItemAsync(
|
||||
var handle = await dataSubscription.AddDataChangeMonitoredItemAsync(
|
||||
nodeId, intervalMs, OnDataChangeNotification, CancellationToken.None);
|
||||
|
||||
lock (_subscriptionLock)
|
||||
@@ -817,9 +971,10 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
try
|
||||
{
|
||||
var monitorNode = sourceNodeId ?? ObjectIds.Server;
|
||||
_alarmSubscription = await _session!.CreateSubscriptionAsync(intervalMs, CancellationToken.None);
|
||||
var replayedAlarms = await session.CreateSubscriptionAsync(intervalMs, CancellationToken.None);
|
||||
lock (_subscriptionLock) _alarmSubscription = replayedAlarms;
|
||||
var filter = CreateAlarmEventFilter();
|
||||
await _alarmSubscription.AddEventMonitoredItemAsync(
|
||||
await replayedAlarms.AddEventMonitoredItemAsync(
|
||||
monitorNode, intervalMs, filter, OnAlarmEventNotification, CancellationToken.None);
|
||||
|
||||
lock (_subscriptionLock)
|
||||
@@ -937,9 +1092,13 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
|
||||
private void TransitionState(ConnectionState newState, string endpointUrl)
|
||||
{
|
||||
var oldState = _state;
|
||||
if (oldState == newState) return;
|
||||
_state = newState;
|
||||
ConnectionState oldState;
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
oldState = _state;
|
||||
if (oldState == newState) return;
|
||||
_state = newState;
|
||||
}
|
||||
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(oldState, newState, endpointUrl));
|
||||
}
|
||||
|
||||
|
||||
@@ -43,23 +43,9 @@ public partial class HistoryViewModel : ObservableObject
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
/// <summary>Available aggregate types (null means "Raw").</summary>
|
||||
/// <summary>Available aggregate types (null means "Raw"): every client aggregate, which is the served set.</summary>
|
||||
public IReadOnlyList<AggregateType?> AggregateTypes { get; } =
|
||||
[
|
||||
null,
|
||||
AggregateType.Average,
|
||||
AggregateType.Minimum,
|
||||
AggregateType.Maximum,
|
||||
AggregateType.Count,
|
||||
AggregateType.Start,
|
||||
AggregateType.End,
|
||||
AggregateType.StandardDeviation,
|
||||
AggregateType.TimeAverage,
|
||||
AggregateType.TimeAverage2,
|
||||
AggregateType.Total,
|
||||
AggregateType.Total2,
|
||||
AggregateType.Interpolative
|
||||
];
|
||||
[null, .. Enum.GetValues<AggregateType>().Select(a => (AggregateType?)a)];
|
||||
|
||||
/// <summary>Gets a value indicating whether an aggregate read is selected.</summary>
|
||||
public bool IsAggregateRead => SelectedAggregateType != null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Opc.Ua;
|
||||
@@ -19,6 +20,11 @@ public partial class SubscriptionsViewModel : ObservableObject
|
||||
private readonly IUiDispatcher _dispatcher;
|
||||
private readonly IOpcUaClientService _service;
|
||||
|
||||
// NodeId -> its rows, kept in step with ActiveSubscriptions by OnActiveSubscriptionsChanged, so a notification is
|
||||
// one lookup instead of a scan of every row on the UI thread (2 000 items at 1 Hz was 4M string compares/s).
|
||||
// Touched only on the UI thread, like the collection itself.
|
||||
private readonly Dictionary<string, List<SubscriptionItemViewModel>> _rowsByNodeId = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Last user-visible status message — set when a subscribe/unsubscribe operation fails so the
|
||||
/// shell can surface the diagnostic instead of silently dropping the error. Cleared on success.
|
||||
@@ -49,9 +55,13 @@ public partial class SubscriptionsViewModel : ObservableObject
|
||||
{
|
||||
_service = service;
|
||||
_dispatcher = dispatcher;
|
||||
ActiveSubscriptions.CollectionChanged += OnActiveSubscriptionsChanged;
|
||||
_service.DataChanged += OnDataChanged;
|
||||
}
|
||||
|
||||
/// <summary>The number of distinct NodeIds in the notification index (test seam).</summary>
|
||||
internal int IndexedNodeCount => _rowsByNodeId.Count;
|
||||
|
||||
/// <summary>Currently active subscriptions.</summary>
|
||||
public ObservableCollection<SubscriptionItemViewModel> ActiveSubscriptions { get; } = [];
|
||||
|
||||
@@ -62,16 +72,42 @@ public partial class SubscriptionsViewModel : ObservableObject
|
||||
{
|
||||
_dispatcher.Post(() =>
|
||||
{
|
||||
foreach (var item in ActiveSubscriptions)
|
||||
if (item.NodeId == e.NodeId)
|
||||
{
|
||||
item.Value = Helpers.ValueFormatter.Format(e.Value.Value);
|
||||
item.Status = Helpers.StatusCodeFormatter.Format(e.Value.StatusCode);
|
||||
item.Timestamp = e.Value.SourceTimestamp.ToString("O");
|
||||
}
|
||||
if (!_rowsByNodeId.TryGetValue(e.NodeId, out var rows)) return;
|
||||
foreach (var item in rows)
|
||||
{
|
||||
item.Value = Helpers.ValueFormatter.Format(e.Value.Value);
|
||||
item.Status = Helpers.StatusCodeFormatter.Format(e.Value.StatusCode);
|
||||
item.Timestamp = e.Value.SourceTimestamp.ToString("O");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void OnActiveSubscriptionsChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (e.Action == NotifyCollectionChangedAction.Reset)
|
||||
{
|
||||
_rowsByNodeId.Clear();
|
||||
foreach (var item in ActiveSubscriptions) Index(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.OldItems != null)
|
||||
foreach (SubscriptionItemViewModel item in e.OldItems)
|
||||
if (_rowsByNodeId.TryGetValue(item.NodeId, out var rows) && rows.Remove(item) && rows.Count == 0)
|
||||
_rowsByNodeId.Remove(item.NodeId);
|
||||
|
||||
if (e.NewItems != null)
|
||||
foreach (SubscriptionItemViewModel item in e.NewItems)
|
||||
Index(item);
|
||||
}
|
||||
|
||||
private void Index(SubscriptionItemViewModel item)
|
||||
{
|
||||
if (!_rowsByNodeId.TryGetValue(item.NodeId, out var rows))
|
||||
_rowsByNodeId[item.NodeId] = rows = [];
|
||||
rows.Add(item);
|
||||
}
|
||||
|
||||
private bool CanAddSubscription()
|
||||
{
|
||||
return IsConnected && !string.IsNullOrWhiteSpace(NewNodeIdText);
|
||||
@@ -149,7 +185,7 @@ public partial class SubscriptionsViewModel : ObservableObject
|
||||
if (!IsConnected || string.IsNullOrWhiteSpace(nodeIdStr)) return;
|
||||
|
||||
// Skip if already subscribed
|
||||
if (ActiveSubscriptions.Any(s => s.NodeId == nodeIdStr)) return;
|
||||
if (_rowsByNodeId.ContainsKey(nodeIdStr)) return;
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -20,11 +20,24 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// until this table follows.
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Modbus</b> — <c>region: Coils</c> (FC15, any element, at most 1968 coils) and
|
||||
/// <c>region: HoldingRegisters</c> with a fixed-width numeric <c>dataType</c> (FC16) whose
|
||||
/// <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>Modbus</b> — tags addressed by the <c>region</c> + <c>address</c> KEYS (the
|
||||
/// <c>ModbusTagAddressResolution</c> rule: keys win whenever both are present), read as strictly as the
|
||||
/// driver reads them (<c>address</c> an integer 0–65535; a present <c>region</c> / <c>dataType</c> /
|
||||
/// <c>byteOrder</c> / <c>stringByteOrder</c> string must name a member). <c>region: Coils</c>: FC15, at most
|
||||
/// 1968 coils, with ANY <c>dataType</c> — the resolver coerces a bit region to Bool and never refuses it.
|
||||
/// <c>region: HoldingRegisters</c> (FC16), never Bool: a fixed-width numeric <c>dataType</c>,
|
||||
/// <c>String</c> (<c>(stringLength+1)/2</c> registers per element, <c>stringLength >= 1</c>) or
|
||||
/// <c>BitInRegister</c> (one register per element; <c>bitIndex</c> 0–15), whose
|
||||
/// <c>arrayLength × registers-per-element</c> fits the driver's <c>maxRegistersPerWrite</c> (default 123 —
|
||||
/// the driver refuses rather than chunks a longer FC16) and never more than FC16's protocol ceiling of 123,
|
||||
/// however high the cap is configured (a conformant device refuses a longer FC16, so that write always
|
||||
/// fails). A BitInRegister array is read-modify-written — one unchunked FC03 of the span, then one FC16 —
|
||||
/// so it is also bounded by <c>maxRegistersPerRead</c> (default and protocol ceiling 125). Every span
|
||||
/// (registers or coils) ends at or before address 65535. FC23 is opt-in, capped lower and falls back to
|
||||
/// FC16, so it never changes the answer. Input
|
||||
/// regions are read-only. A string-only tag (an <c>addressString</c> with no keys) is resolved by the
|
||||
/// driver with ITS family through a parser this assembly cannot reference, so it cannot be proven
|
||||
/// writable here and stays read-only.</item>
|
||||
/// <item><b>S7</b> — <c>dataType</c> Byte / Int16 / UInt16 / Int32 / UInt32 / Float32 (one contiguous
|
||||
/// block, <c>EncodeArrayBlock</c>). Bool arrays would clobber neighbouring bits; the wide / String /
|
||||
/// DateTime element types have no array encoder.</item>
|
||||
@@ -61,9 +74,12 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// <para><b>Elements convert; they are not exact-matched.</b> ScadaBridge writes CLR arrays such as
|
||||
/// <c>int[]</c> / <c>List<int></c> to Int16 nodes; the node manager converts a numeric array to the
|
||||
/// node's element type, and the AbCip, AbLegacy and TwinCAT encoders then <c>Convert</c> each element to the
|
||||
/// controller's type (an element that does not fit is <c>BadOutOfRange</c>). So those rows do not key on an
|
||||
/// exact node <c>DataType</c>; they leave out only the node types whose elements NEVER convert: a
|
||||
/// <c>DateTime</c> or <c>String</c> node over a numeric element (AbCip, AbLegacy, TwinCAT — TwinCAT keeps
|
||||
/// controller's type (an element that does not fit is <c>BadOutOfRange</c>); Modbus <c>Convert</c>s each element to
|
||||
/// the register / coil type the same way. So those rows do not key on an exact node <c>DataType</c>; they leave
|
||||
/// out only the node types whose elements the server cannot promise to convert: a <c>DateTime</c> node over a
|
||||
/// numeric element (it never converts) or a <c>String</c> node over one (it converts only when the text happens
|
||||
/// to parse, <c>"1"</c> / <c>"true"</c>) — on Modbus over a numeric or bit element, and a Modbus String element
|
||||
/// takes any node type; likewise on AbCip, AbLegacy and TwinCAT — TwinCAT keeps
|
||||
/// DATE / DT and WSTRING under a DateTime node, and STRING / WSTRING under a String node; a DateTime's text
|
||||
/// form is culture-dependent, so a STRING under a DateTime node is left out), and a <c>Boolean</c> node on
|
||||
/// AbLegacy, whose encoder refuses bool elements. An unknown DataType name (served as BaseDataType) hands the
|
||||
@@ -74,13 +90,26 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
public static class DriverArrayWriteSupport
|
||||
{
|
||||
/// <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>
|
||||
/// (<c>ModbusDriver</c> resolves it the same way). A configured value only ever LOWERS it — see
|
||||
/// <see cref="ModbusProtocolMaxRegistersPerWrite"/>.</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;
|
||||
|
||||
/// <summary>Modbus FC16's protocol ceiling on registers per request (0x7B). A configured
|
||||
/// <c>maxRegistersPerWrite</c> above it never lifts it: a conformant device refuses a longer FC16.</summary>
|
||||
public const int ModbusProtocolMaxRegistersPerWrite = 123;
|
||||
|
||||
/// <summary>Modbus FC03's protocol ceiling on registers per request (0x7D) — the bound on a BitInRegister[]
|
||||
/// write's read-modify-write read, together with a configured <c>maxRegistersPerRead</c>.</summary>
|
||||
public const int ModbusProtocolMaxRegistersPerRead = 125;
|
||||
|
||||
/// <summary>The last Modbus address (registers and coils alike): a span ending past it is refused by every
|
||||
/// conformant device (exception 02).</summary>
|
||||
private const long ModbusLastAddress = ushort.MaxValue;
|
||||
|
||||
// Element type → registers per element (ModbusDriver.RegisterCount).
|
||||
private static readonly Dictionary<string, int> ModbusRegisterArrayTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
@@ -89,6 +118,30 @@ public static class DriverArrayWriteSupport
|
||||
["Int64"] = 4, ["UInt64"] = 4, ["Float64"] = 4,
|
||||
};
|
||||
|
||||
// The Modbus enums' member names (ModbusRegion / ModbusDataType / ModbusByteOrder / ModbusStringByteOrder in
|
||||
// Driver.Modbus.Addressing, which this assembly cannot reference). The parity tests enumerate the real enums, so a
|
||||
// member added there without being added here fails a test.
|
||||
private static readonly HashSet<string> ModbusRegions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Coils", "DiscreteInputs", "InputRegisters", "HoldingRegisters",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> ModbusDataTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Bool", "Int16", "UInt16", "Int32", "UInt32", "Int64", "UInt64", "Float32", "Float64",
|
||||
"BitInRegister", "String", "Bcd16", "Bcd32",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> ModbusByteOrders = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"BigEndian", "WordSwap", "ByteSwap", "FullReverse",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> ModbusStringByteOrders = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"HighByteFirst", "LowByteFirst",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> S7ArrayTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Byte", "Int16", "UInt16", "Int32", "UInt32", "Float32",
|
||||
@@ -148,22 +201,7 @@ public static class DriverArrayWriteSupport
|
||||
return dataType is not null && GalaxyArrayDataTypes.Contains(dataType)
|
||||
&& TryParseObject(tagConfig, out var galaxyRoot) && ReadArrayLength(galaxyRoot) is not null;
|
||||
case DriverTypeNames.Modbus:
|
||||
{
|
||||
// Same reads as ModbusTagDefinitionFactory: a blob with no `region` or no numeric `address` is not
|
||||
// a Modbus tag at all (the factory refuses it). A present non-string region is read as absent ⇒
|
||||
// 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 native = ReadEnumName(root, "dataType") ?? "Int16";
|
||||
if (string.Equals(region, "Coils", StringComparison.OrdinalIgnoreCase)) return count <= ModbusMaxCoilsPerWrite;
|
||||
return string.Equals(region, "HoldingRegisters", StringComparison.OrdinalIgnoreCase)
|
||||
&& ModbusRegisterArrayTypes.TryGetValue(native, out var registersPerElement)
|
||||
&& (long)count * registersPerElement <= ModbusMaxRegistersPerWrite(driverConfigs);
|
||||
}
|
||||
return ModbusSupports(dataType, tagConfig, driverConfigs);
|
||||
case DriverTypeNames.S7:
|
||||
{
|
||||
// S7TagDefinitionFactory requires a dataType key (absent ⇒ not an S7 tag at all); a present
|
||||
@@ -185,6 +223,81 @@ public static class DriverArrayWriteSupport
|
||||
}
|
||||
}
|
||||
|
||||
// ModbusTagAddressResolution + ModbusTagDefinitionFactory + ModbusDriver.WriteAsync, KEYS shape only:
|
||||
// - `region` AND `address` both present (Classify == Keys). Anything else is a string-only (`addressString`) blob the
|
||||
// driver resolves with ITS family through ModbusAddressParser, which this assembly cannot reference — so it is
|
||||
// never proven writable here and stays read-only (conservative: a hidden write, never an advertised refusal).
|
||||
// - the strict key reads: `address` an integer 0–65535; a present string `region` / `dataType` / `byteOrder` /
|
||||
// `stringByteOrder` must be a member name (a non-string token is read as absent ⇒ the default).
|
||||
// - Coils: the resolver COERCES any type to Bool (never refuses); one FC15 of at most 1968 coils.
|
||||
// - HoldingRegisters: never Bool; fixed-width numeric, String ((stringLength+1)/2 registers, stringLength >= 1) or
|
||||
// BitInRegister (one register each, bitIndex 0–15); arrayLength × registers per element within
|
||||
// maxRegistersPerWrite clamped to FC16's 123 (the driver refuses rather than chunks; FC23 is opt-in and capped
|
||||
// lower, falling back to FC16, so it never changes the answer). BitInRegister[] also READS its span first in one
|
||||
// unchunked FC03, so it is bounded by maxRegistersPerRead (clamped to FC03's 125) too.
|
||||
// - Every span ends at or before address 65535 (a conformant device refuses one past it with exception 02).
|
||||
// - Elements CONVERT, so the node type is not exact-matched. A DateTime node over a numeric or bit element never
|
||||
// converts; a String node over one converts only when the text happens to parse ("1", "true"), which the server
|
||||
// cannot promise — both are left out. A String element Convert.ToString()s any node type.
|
||||
private static bool ModbusSupports(string? dataType, string? tagConfig, string?[]? driverConfigs)
|
||||
{
|
||||
if (!TryParseObject(tagConfig, out var root)
|
||||
|| !root.TryGetProperty("region", out _)
|
||||
|| !root.TryGetProperty("address", out var addressEl) || addressEl.ValueKind != JsonValueKind.Number
|
||||
|| !addressEl.TryGetInt32(out var address) || address is < 0 or > ushort.MaxValue
|
||||
|| ReadArrayLength(root) is not { } count
|
||||
|| !TryReadModbusEnum(root, "region", ModbusRegions, out var region)
|
||||
|| !TryReadModbusEnum(root, "dataType", ModbusDataTypes, out var native)
|
||||
|| !TryReadModbusEnum(root, "byteOrder", ModbusByteOrders, out _)
|
||||
|| !TryReadModbusEnum(root, "stringByteOrder", ModbusStringByteOrders, out _))
|
||||
return false;
|
||||
region ??= "HoldingRegisters";
|
||||
|
||||
if (region.Equals("Coils", StringComparison.OrdinalIgnoreCase))
|
||||
return count <= ModbusMaxCoilsPerWrite && address + (long)count - 1 <= ModbusLastAddress
|
||||
&& dataType is not ("String" or "DateTime");
|
||||
if (!region.Equals("HoldingRegisters", StringComparison.OrdinalIgnoreCase)) return false; // input regions are read-only
|
||||
|
||||
native ??= "Int16";
|
||||
int registersPerElement;
|
||||
if (native.Equals("String", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// The factory narrows to ushort ((ushort)ReadInt) and refuses a zero length.
|
||||
var stringLength = (ushort)ReadInt(root, "stringLength");
|
||||
if (stringLength < 1) return false;
|
||||
registersPerElement = (stringLength + 1) / 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (dataType is "String" or "DateTime") return false;
|
||||
if (native.Equals("BitInRegister", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// The factory narrows to byte ((byte)ReadInt) and refuses a bit above 15. The RMW reads the whole span
|
||||
// in ONE unchunked FC03 before writing it, so the read cap bounds it too.
|
||||
if ((byte)ReadInt(root, "bitIndex") > 15 || count > ModbusMaxRegistersPerRead(driverConfigs)) return false;
|
||||
registersPerElement = 1;
|
||||
}
|
||||
else if (!ModbusRegisterArrayTypes.TryGetValue(native, out registersPerElement))
|
||||
{
|
||||
return false; // Bool in a register region: the factory refuses the pair
|
||||
}
|
||||
}
|
||||
var registers = (long)count * registersPerElement;
|
||||
return registers <= ModbusMaxRegistersPerWrite(driverConfigs) && address + registers - 1 <= ModbusLastAddress;
|
||||
}
|
||||
|
||||
// TagConfigJson.TryReadEnumStrict over the Modbus enum's member names: absent or a non-string token ⇒ null (the
|
||||
// caller applies the default); a string must name a member (trimmed, case-insensitive), else the tag is refused.
|
||||
// Numeric spellings are not member names, so they are refused exactly as ClassifyEnumText refuses them.
|
||||
private static bool TryReadModbusEnum(JsonElement root, string name, HashSet<string> members, out string? value)
|
||||
{
|
||||
value = ReadEnumName(root, name)?.Trim();
|
||||
return value is null || members.Contains(value);
|
||||
}
|
||||
|
||||
private static int ReadInt(JsonElement root, string name) =>
|
||||
root.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number && el.TryGetInt32(out var v) ? v : 0;
|
||||
|
||||
// AbCipTagDefinitionFactory + AbCipDriver.WriteAsync: a non-blank string tagPath, an atomic element type the codec
|
||||
// has a width for (BOOL arrays are bit-packed DWORDs, STRING / structure elements are structures), a declared
|
||||
// length, and not a safety tag (refused BadNotWritable). Each element is CONVERTED, so any numeric / Boolean node
|
||||
@@ -338,9 +451,24 @@ public static class DriverArrayWriteSupport
|
||||
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)
|
||||
// The effective Modbus FC16 write cap: the smallest maxRegistersPerWrite configured in any config (absent /
|
||||
// non-positive ⇒ the driver default, 123), CLAMPED to FC16's protocol ceiling of 123. The driver trusts a larger
|
||||
// configured cap and sends the over-ceiling FC16 (past 127 registers its one-byte byte-count field even wraps), which
|
||||
// every conformant device refuses — so such a write always fails and is never advertised.
|
||||
private static int ModbusMaxRegistersPerWrite(string?[]? driverConfigs) =>
|
||||
Math.Min(SmallestPositive(driverConfigs, "maxRegistersPerWrite") ?? ModbusDefaultMaxRegistersPerWrite,
|
||||
ModbusProtocolMaxRegistersPerWrite);
|
||||
|
||||
// The effective Modbus FC03 read cap: the smallest maxRegistersPerRead configured (absent / non-positive ⇒ the driver
|
||||
// default, 125), clamped to FC03's protocol ceiling of 125. Only the BitInRegister[] write reads (its RMW issues one
|
||||
// unchunked FC03 of the whole span), so only that shape is bounded by it.
|
||||
private static int ModbusMaxRegistersPerRead(string?[]? driverConfigs) =>
|
||||
Math.Min(SmallestPositive(driverConfigs, "maxRegistersPerRead") ?? ModbusProtocolMaxRegistersPerRead,
|
||||
ModbusProtocolMaxRegistersPerRead);
|
||||
|
||||
// The smallest positive Int32 value of `key` (matched case-insensitively, like the driver's DTO binding) at the root
|
||||
// of any config; null when none carries one.
|
||||
private static int? SmallestPositive(string?[]? driverConfigs, string key)
|
||||
{
|
||||
int? cap = null;
|
||||
foreach (var config in driverConfigs ?? [])
|
||||
@@ -348,7 +476,7 @@ public static class DriverArrayWriteSupport
|
||||
if (!TryParseObject(config, out var root)) continue;
|
||||
foreach (var prop in root.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(prop.Name, "maxRegistersPerWrite", StringComparison.OrdinalIgnoreCase)
|
||||
if (string.Equals(prop.Name, key, StringComparison.OrdinalIgnoreCase)
|
||||
&& prop.Value.ValueKind == JsonValueKind.Number
|
||||
&& prop.Value.TryGetInt32(out var configured) && configured > 0)
|
||||
{
|
||||
@@ -356,7 +484,7 @@ public static class DriverArrayWriteSupport
|
||||
}
|
||||
}
|
||||
}
|
||||
return cap ?? ModbusDefaultMaxRegistersPerWrite;
|
||||
return cap;
|
||||
}
|
||||
|
||||
// The declared element count: isArray is the JSON literal true AND arrayLength is an Int32 >= 1 — the rule the
|
||||
|
||||
@@ -44,17 +44,21 @@ public sealed class ProbeCommand : AbCipCommandBase
|
||||
await using var driver = new AbCipDriver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var snapshot = await driver.ReadAsync(["__probe"], ct);
|
||||
var health = driver.GetHealth();
|
||||
// Ctrl-C prints "Cancelled." instead of an OperationCanceledException stack trace (review 07 U7).
|
||||
await RunCancellableAsync(console, ct, async () =>
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var snapshot = await driver.ReadAsync(["__probe"], ct);
|
||||
var health = driver.GetHealth();
|
||||
|
||||
await console.Output.WriteLineAsync($"Gateway: {Gateway}");
|
||||
await console.Output.WriteLineAsync($"Family: {Family}");
|
||||
await console.Output.WriteLineAsync($"Health: {health.State}");
|
||||
if (health.LastError is { } err)
|
||||
await console.Output.WriteLineAsync($"Last error: {err}");
|
||||
await console.Output.WriteLineAsync();
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.Format(TagPath, snapshot[0]));
|
||||
await console.Output.WriteLineAsync($"Gateway: {Gateway}");
|
||||
await console.Output.WriteLineAsync($"Family: {Family}");
|
||||
await console.Output.WriteLineAsync($"Health: {health.State}");
|
||||
if (health.LastError is { } err)
|
||||
await console.Output.WriteLineAsync($"Last error: {err}");
|
||||
await console.Output.WriteLineAsync();
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.Format(TagPath, snapshot[0]));
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -45,9 +45,13 @@ public sealed class ReadCommand : AbCipCommandBase
|
||||
await using var driver = new AbCipDriver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var snapshot = await driver.ReadAsync([tagName], ct);
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.Format(TagPath, snapshot[0]));
|
||||
// Ctrl-C prints "Cancelled." instead of an OperationCanceledException stack trace (review 07 U7).
|
||||
await RunCancellableAsync(console, ct, async () =>
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var snapshot = await driver.ReadAsync([tagName], ct);
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.Format(TagPath, snapshot[0]));
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -50,23 +50,22 @@ public sealed class SubscribeCommand : AbCipCommandBase
|
||||
|
||||
await using var driver = new AbCipDriver(options, DriverInstanceId);
|
||||
ISubscriptionHandle? handle = null;
|
||||
// The poll engine raises OnDataChange on a background thread: serialise its lines against each other and
|
||||
// against this thread's own, and never let a failed console write fault the poll loop (review 07 U7).
|
||||
var writeLock = new object();
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
|
||||
await console.Output.WriteLineAsync(
|
||||
$"Subscribed to {TagPath} @ {IntervalMs}ms. Ctrl+C to stop.");
|
||||
|
||||
driver.OnDataChange += (_, e) =>
|
||||
{
|
||||
var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " +
|
||||
$"{e.FullReference} = {SnapshotFormatter.FormatValue(e.Snapshot.Value)} " +
|
||||
$"({SnapshotFormatter.FormatStatus(e.Snapshot.StatusCode)})";
|
||||
console.Output.WriteLine(line);
|
||||
};
|
||||
driver.OnDataChange += CreateChangeLineWriter(console.Output, writeLock);
|
||||
|
||||
handle = await driver.SubscribeAsync([tagName], TimeSpan.FromMilliseconds(IntervalMs), ct);
|
||||
|
||||
lock (writeLock)
|
||||
{
|
||||
console.Output.WriteLine($"Subscribed to {TagPath} @ {IntervalMs}ms. Ctrl+C to stop.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct);
|
||||
@@ -76,6 +75,10 @@ public sealed class SubscribeCommand : AbCipCommandBase
|
||||
// Expected on Ctrl+C.
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// Ctrl+C while still connecting/subscribing: a clean exit, not a stack trace (review 07 U7).
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (handle is not null)
|
||||
|
||||
@@ -57,9 +57,13 @@ public sealed class WriteCommand : AbCipCommandBase
|
||||
await using var driver = new AbCipDriver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(TagPath, results[0]));
|
||||
// Ctrl-C prints "Cancelled." instead of an OperationCanceledException stack trace (review 07 U7).
|
||||
await RunCancellableAsync(console, ct, async () =>
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(TagPath, results[0]));
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -32,27 +32,6 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
|
||||
"LogixPccc unverified).")]
|
||||
public AbLegacyCliTransport Transport { get; init; } = AbLegacyCliTransport.Default;
|
||||
|
||||
/// <summary>
|
||||
/// Runs a one-shot command's driver work so a Ctrl-C (cancellation of <paramref name="ct"/>) prints
|
||||
/// <c>Cancelled.</c> instead of an unhandled <see cref="OperationCanceledException"/> stack trace (review 07 U7).
|
||||
/// Any other exception — and an OCE the operator did not ask for — still propagates.
|
||||
/// </summary>
|
||||
/// <param name="console">The CLI console.</param>
|
||||
/// <param name="ct">The console's Ctrl-C token.</param>
|
||||
/// <param name="body">The command body.</param>
|
||||
/// <returns>A task that completes when the body has finished or was cancelled.</returns>
|
||||
internal static async ValueTask RunCancellableAsync(CliFx.Infrastructure.IConsole console, CancellationToken ct, Func<Task> body)
|
||||
{
|
||||
try
|
||||
{
|
||||
await body();
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
await console.Error.WriteLineAsync("Cancelled.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets the per-operation timeout in milliseconds.</summary>
|
||||
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
|
||||
public int TimeoutMs { get; init; } = 5000;
|
||||
|
||||
@@ -79,5 +79,61 @@ public abstract class DriverCommandBase : ICommand
|
||||
/// </summary>
|
||||
protected static void FlushLogging() => Log.CloseAndFlush();
|
||||
|
||||
/// <summary>
|
||||
/// Runs a one-shot command's driver work so a Ctrl-C (cancellation of <paramref name="ct"/>) prints
|
||||
/// <c>Cancelled.</c> instead of an unhandled <see cref="OperationCanceledException"/> stack trace (review 07 U7).
|
||||
/// Any other exception — and an OCE the operator did not ask for — still propagates.
|
||||
/// </summary>
|
||||
/// <param name="console">The CLI console.</param>
|
||||
/// <param name="ct">The console's Ctrl-C token.</param>
|
||||
/// <param name="body">The command body.</param>
|
||||
/// <returns>A task that completes when the body has finished or was cancelled.</returns>
|
||||
public static async ValueTask RunCancellableAsync(IConsole console, CancellationToken ct, Func<Task> body)
|
||||
{
|
||||
try
|
||||
{
|
||||
await body();
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
await console.Error.WriteLineAsync("Cancelled.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <c>OnDataChange</c> handler a <c>subscribe</c> command attaches: one
|
||||
/// <c>[HH:mm:ss.fff] ref = value (status)</c> line per change. Writes are serialised on
|
||||
/// <paramref name="writeLock"/> (the poll engine raises from a background thread, concurrently with the
|
||||
/// command thread's own lines), and a failing console write (closed pipe, redirected stream error) is logged
|
||||
/// and swallowed so it cannot fault the driver's poll loop (review 07 U7).
|
||||
/// </summary>
|
||||
/// <param name="output">The console output writer.</param>
|
||||
/// <param name="writeLock">The lock every console write of the command takes.</param>
|
||||
/// <returns>The event handler.</returns>
|
||||
public static EventHandler<ZB.MOM.WW.OtOpcUa.Core.Abstractions.DataChangeEventArgs> CreateChangeLineWriter(
|
||||
TextWriter output, object writeLock)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(output);
|
||||
ArgumentNullException.ThrowIfNull(writeLock);
|
||||
return (_, e) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " +
|
||||
$"{e.FullReference} = {SnapshotFormatter.FormatValue(e.Snapshot.Value)} " +
|
||||
$"({SnapshotFormatter.FormatStatus(e.Snapshot.StatusCode)})";
|
||||
lock (writeLock)
|
||||
{
|
||||
output.WriteLine(line);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Warning(ex, "subscribe: console write failed for {Tag}; continuing the poll loop.",
|
||||
e.FullReference);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private bool _loggingConfigured;
|
||||
}
|
||||
|
||||
@@ -101,7 +101,11 @@ public static class SnapshotFormatter
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
/// <summary>Formats a value for console output, handling null, bool, string, and formattable types.</summary>
|
||||
/// <summary>
|
||||
/// Formats a value for console output, handling null, bool, string, formattable and array/list types. An
|
||||
/// array renders its elements (<c>[1, 2, 3]</c>, each formatted as a scalar) rather than its CLR type name
|
||||
/// (<c>System.Int32[]</c>).
|
||||
/// </summary>
|
||||
/// <param name="value">The value to format.</param>
|
||||
/// <returns>A formatted string representation of the value.</returns>
|
||||
public static string FormatValue(object? value) => value switch
|
||||
@@ -110,9 +114,23 @@ public static class SnapshotFormatter
|
||||
bool b => b ? "true" : "false",
|
||||
string s => $"\"{s}\"",
|
||||
IFormattable f => f.ToString(null, CultureInfo.InvariantCulture),
|
||||
System.Collections.IEnumerable items => FormatSequence(items),
|
||||
_ => value.ToString() ?? "<null>",
|
||||
};
|
||||
|
||||
private static string FormatSequence(System.Collections.IEnumerable items)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder("[");
|
||||
var first = true;
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (!first) sb.Append(", ");
|
||||
sb.Append(FormatValue(item));
|
||||
first = false;
|
||||
}
|
||||
return sb.Append(']').ToString();
|
||||
}
|
||||
|
||||
/// <summary>Formats an OPC UA status code as a hexadecimal string with named severity classification.</summary>
|
||||
/// <param name="statusCode">The OPC UA status code to format.</param>
|
||||
/// <returns>A formatted status string like "0x00000000 (Good)" or "0x80050000 (BadCommunicationError)".</returns>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="AbTransportException"/> whose status came OFF THE WIRE: the device (or the bridge's Execute PCCC
|
||||
/// object) replied with a non-zero CIP envelope status or PCCC STS. It lets a driver tell a refusal the device
|
||||
/// ANSWERED from the same <see cref="AbStatus"/> raised locally before anything was sent — most importantly
|
||||
/// <see cref="AbStatus.Unsupported"/>, which a native runtime also throws for operations it refuses pre-wire
|
||||
/// (review lane K I2).
|
||||
/// </summary>
|
||||
/// <param name="status">The status mapped from the reply.</param>
|
||||
/// <param name="message">The exception message.</param>
|
||||
public sealed class AbDeviceReplyException(AbStatus status, string message) : AbTransportException(status, message);
|
||||
@@ -1,39 +1,81 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Maps wire statuses (CIP general/extended, PCCC STS/EXT STS) to the transport-neutral <see cref="AbStatus"/> the
|
||||
/// drivers classify. The class an <see cref="AbStatus"/> lands in is load-bearing for driver health (#527/#737/#738):
|
||||
/// an ANSWERED refusal (NotFound / NotAllowed / OutOfBounds / TooLarge / TooSmall / TypeMismatch /
|
||||
/// DeviceStateConflict, and for AbCip Unsupported) proves the device replied; <see cref="AbStatus.BadConnection"/>
|
||||
/// is the no-reply comms class; <see cref="AbStatus.BadReply"/> / <see cref="AbStatus.Partial"/> are a reply that
|
||||
/// cannot be classified. Every code not named below is deliberately <see cref="AbStatus.BadReply"/>.
|
||||
/// </summary>
|
||||
public static class AbStatusCodes
|
||||
{
|
||||
/// <summary>Maps a CIP general status (and optional extended status words) to an <see cref="AbStatus"/>.</summary>
|
||||
/// <param name="general">The CIP general status byte.</param>
|
||||
/// <param name="extended">The extended status words, if any.</param>
|
||||
/// <returns>The mapped status.</returns>
|
||||
/// <remarks>
|
||||
/// 0x01 (connection failure, incl. a routing hop's unconnected-send timeout) and 0x07 (connection lost) stay
|
||||
/// <see cref="AbStatus.BadReply"/> on purpose: a bridge reports a dead END device that way, so they are neither an
|
||||
/// answer nor proof of no reply (AbLegacy #738 "opaque"). A Forward Open refused with one of the request-level
|
||||
/// codes (0x1C / 0x20 / 0xFF-0x2104) stays a connection failure — see
|
||||
/// <c>ConnectionManagerServices.ParseForwardOpenReply</c>.
|
||||
/// </remarks>
|
||||
public static AbStatus FromCip(byte general, ReadOnlySpan<ushort> extended) => general switch
|
||||
{
|
||||
0x00 or 0x0B => AbStatus.Ok,
|
||||
0x04 or 0x05 or 0x16 => AbStatus.NotFound,
|
||||
0x06 => AbStatus.Partial,
|
||||
0x08 => AbStatus.Unsupported,
|
||||
0x0A => AbStatus.OutOfBounds,
|
||||
0x0C or 0x10 => AbStatus.DeviceStateConflict,
|
||||
0x0E or 0x0F => AbStatus.NotAllowed,
|
||||
0x13 => AbStatus.TooSmall,
|
||||
0x15 => AbStatus.TooLarge,
|
||||
0xFF when extended.Length > 0 && extended[0] == 0x2105 => AbStatus.OutOfBounds,
|
||||
0xFF when extended.Length > 0 && extended[0] == 0x2107 => AbStatus.TypeMismatch,
|
||||
0x00 or 0x0B => AbStatus.Ok, // success / already in requested mode
|
||||
0x04 or 0x05 or 0x16 => AbStatus.NotFound, // path segment error / path unknown / object does not exist
|
||||
0x06 => AbStatus.Partial, // partial transfer (fragmented read continues)
|
||||
0x08 => AbStatus.Unsupported, // service not supported
|
||||
0x0A => AbStatus.OutOfBounds, // attribute list error
|
||||
0x0C or 0x10 => AbStatus.DeviceStateConflict, // object state conflict / device state conflict
|
||||
0x0E or 0x0F => AbStatus.NotAllowed, // attribute not settable / privilege violation
|
||||
0x13 => AbStatus.TooSmall, // not enough data
|
||||
0x15 => AbStatus.TooLarge, // too much data
|
||||
// Lane K F1 (#737): the controller answered and refused this one request — not a garbled reply, so not a
|
||||
// comms fault that degrades the driver and evicts the runtime.
|
||||
0x1C => AbStatus.Unsupported, // missing attribute list entry data
|
||||
0x20 => AbStatus.Unsupported, // invalid parameter
|
||||
0xFF when extended.Length > 0 && extended[0] == 0x2104 => AbStatus.OutOfBounds, // offset beyond end of tag
|
||||
0xFF when extended.Length > 0 && extended[0] == 0x2105 => AbStatus.OutOfBounds, // beyond end of the object
|
||||
0xFF when extended.Length > 0 && extended[0] == 0x2107 => AbStatus.TypeMismatch, // data type mismatch
|
||||
_ => AbStatus.BadReply,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// True for the CIP statuses Lane K F1 moved from <see cref="AbStatus.BadReply"/> to a request-level refusal.
|
||||
/// A Forward Open refused with one of them is still a connection failure.
|
||||
/// </summary>
|
||||
/// <param name="general">The CIP general status byte.</param>
|
||||
/// <param name="extended">The extended status words, if any.</param>
|
||||
/// <returns><see langword="true"/> for 0x1C, 0x20 and 0xFF/0x2104.</returns>
|
||||
internal static bool IsRequestLevelRefusal(byte general, ReadOnlySpan<ushort> extended) =>
|
||||
general is 0x1C or 0x20 || (general == 0xFF && extended.Length > 0 && extended[0] == 0x2104);
|
||||
|
||||
/// <summary>Maps a PCCC status byte (and optional extended status) to an <see cref="AbStatus"/>.</summary>
|
||||
/// <param name="sts">The PCCC STS byte.</param>
|
||||
/// <param name="extSts">The extended status byte, when present.</param>
|
||||
/// <returns>The mapped status.</returns>
|
||||
/// <remarks>
|
||||
/// Per the DF1/PCCC STS table (1770-6.5.16). Lane K F3 (AbLegacy F8, review 07 r0 I4): 0x20, 0x30 and 0x90 are
|
||||
/// reported by the HOST or a BRIDGE (a DHRIO, a 1761-NET-ENI) when the END device cannot be reached, so they are
|
||||
/// the no-reply comms class. Mapping them to an answered refusal made a dead DH+/DH-485 node behind a bridge count
|
||||
/// as reachable forever — no fail-fast backoff, no comms-loss watchdog.
|
||||
/// </remarks>
|
||||
public static AbStatus FromPccc(byte sts, byte? extSts) => sts switch
|
||||
{
|
||||
0x00 => AbStatus.Ok,
|
||||
0x10 => AbStatus.Unsupported,
|
||||
0x20 => AbStatus.NotFound,
|
||||
0x30 => AbStatus.NotAllowed,
|
||||
0x40 or 0x50 => AbStatus.DeviceStateConflict,
|
||||
0xF0 when extSts is 0x06 or 0x07 => AbStatus.NotFound,
|
||||
_ => AbStatus.BadReply,
|
||||
0x10 => AbStatus.Unsupported, // illegal command or format
|
||||
0x20 => AbStatus.BadConnection, // host has a problem and will not communicate
|
||||
0x30 => AbStatus.BadConnection, // remote node host missing, disconnected or shut down
|
||||
0x40 => AbStatus.DeviceStateConflict, // host could not complete: hardware fault
|
||||
0x50 => AbStatus.NotFound, // addressing problem or memory protect rungs
|
||||
0x60 => AbStatus.NotAllowed, // function not allowed due to command protection
|
||||
0x70 => AbStatus.DeviceStateConflict, // processor is in Program mode
|
||||
0x90 => AbStatus.BadConnection, // remote node cannot buffer command
|
||||
0xB0 => AbStatus.DeviceStateConflict, // remote node problem due to download
|
||||
0xF0 when extSts is 0x06 => AbStatus.NotFound, // EXT: address does not point to something usable
|
||||
0xF0 when extSts is 0x07 => AbStatus.OutOfBounds, // EXT: file is wrong size
|
||||
_ => AbStatus.BadReply, // 0x80, 0xA0, 0xC0, other EXT STS: unclassified
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,8 +85,15 @@ public static class ConnectionManagerServices
|
||||
public static ForwardOpenReply ParseForwardOpenReply(CipReply reply)
|
||||
{
|
||||
if (reply.GeneralStatus != 0)
|
||||
throw new ForwardOpenRefusedException(reply.Status == AbStatus.BadReply ? AbStatus.BadConnection : reply.Status,
|
||||
reply.GeneralStatus, reply.ExtendedStatus);
|
||||
{
|
||||
// An unclassified refusal, or one of the request-level codes AbStatusCodes maps to a per-request refusal
|
||||
// for TAG services (0x1C / 0x20 / 0xFF-0x2104), is a connection failure here: no tag can be served.
|
||||
var status = reply.Status == AbStatus.BadReply
|
||||
|| AbStatusCodes.IsRequestLevelRefusal(reply.GeneralStatus, reply.ExtendedStatus)
|
||||
? AbStatus.BadConnection
|
||||
: reply.Status;
|
||||
throw new ForwardOpenRefusedException(status, reply.GeneralStatus, reply.ExtendedStatus);
|
||||
}
|
||||
var r = new CipReader(reply.Data.Span);
|
||||
var ot = r.U32(); var to = r.U32(); r.U16(); r.U16(); r.U32();
|
||||
return new ForwardOpenReply(ot, to, r.U32(), r.U32());
|
||||
|
||||
@@ -47,6 +47,10 @@ public sealed class EncapsulationClient : IAsyncDisposable
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private long _ctx;
|
||||
private ushort _seq; // connected sequence count; only touched under _gate
|
||||
|
||||
// R07:L5: the 24-byte header of the frame being read. Frames are read only by RunExchangeAsync, which holds _gate
|
||||
// for the whole exchange, so one buffer per session serves every read (it used to be a fresh array per reply).
|
||||
private readonly byte[] _head = new byte[EncapsulationHeader.Size];
|
||||
private volatile bool _faulted;
|
||||
private int _disposed;
|
||||
|
||||
@@ -282,11 +286,12 @@ public sealed class EncapsulationClient : IAsyncDisposable
|
||||
|
||||
private async Task<byte[]> ReadFrameAsync(CancellationToken ct)
|
||||
{
|
||||
var head = new byte[EncapsulationHeader.Size];
|
||||
await _stream.ReadExactlyAsync(head, ct).ConfigureAwait(false);
|
||||
var len = head[2] | (head[3] << 8);
|
||||
// Only called under _gate (RunExchangeAsync), so the shared header buffer is never read concurrently. The frame
|
||||
// itself is allocated exactly: the matched reply's CIP payload is returned as a slice of it and outlives the call.
|
||||
await _stream.ReadExactlyAsync(_head, ct).ConfigureAwait(false);
|
||||
var len = _head[2] | (_head[3] << 8);
|
||||
var frame = new byte[EncapsulationHeader.Size + len];
|
||||
head.CopyTo(frame, 0);
|
||||
_head.CopyTo(frame, 0);
|
||||
await _stream.ReadExactlyAsync(frame.AsMemory(EncapsulationHeader.Size), ct).ConfigureAwait(false);
|
||||
return frame;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Buffers;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
|
||||
|
||||
@@ -52,34 +53,61 @@ public sealed class LogixTagClient(RequestScheduler scheduler, ICipChannel chann
|
||||
var reply = await _scheduler.SendAsync(LogixServices.ReadTagFragmented(path, elements, 0), ct,
|
||||
expectedReplyBytes: ExpectedReadReplyBytes(elements, elementBytes, 0)).ConfigureAwait(false);
|
||||
var (type, handle, first) = LogixServices.ParseReadReply(reply); // throws on any status but 0x00/0x06
|
||||
var data = new List<byte>(first.Length);
|
||||
data.AddRange(first.Span);
|
||||
var lastChunk = first.Length;
|
||||
|
||||
while (reply.GeneralStatus == StatusPartial)
|
||||
// R07:L5: the common single-reply read is one copy of the reply data (it used to go through a List<byte> and a
|
||||
// second ToArray); a fragmented read reassembles in a pooled buffer and copies out once at the end.
|
||||
if (reply.GeneralStatus != StatusPartial) return (type, handle, first.ToArray());
|
||||
|
||||
var data = ArrayPool<byte>.Shared.Rent(Math.Max(first.Length * 4, 1024));
|
||||
var count = 0;
|
||||
try
|
||||
{
|
||||
if (lastChunk == 0)
|
||||
throw new AbTransportException(AbStatus.BadReply, "Partial read reply carried no data; the fragment loop cannot progress.");
|
||||
reply = await _scheduler.SendAsync(
|
||||
LogixServices.ReadTagFragmented(path, elements, (uint)data.Count), ct,
|
||||
expectedReplyBytes: ExpectedReadReplyBytes(elements, elementBytes, data.Count)).ConfigureAwait(false);
|
||||
var (fragmentType, fragmentHandle, chunk) = LogixServices.ParseReadReply(reply);
|
||||
if (fragmentType != type)
|
||||
throw new AbTransportException(AbStatus.BadReply,
|
||||
$"Read fragment type 0x{fragmentType:X4} differs from the first reply's 0x{type:X4}.");
|
||||
// A mid-read change of the structure handle means the tag's type changed under us (a
|
||||
// download / online edit); stitching the halves would yield a value of neither type.
|
||||
if (fragmentHandle != handle)
|
||||
throw new AbTransportException(AbStatus.BadReply,
|
||||
$"Read fragment structure handle {FormatHandle(fragmentHandle)} differs from the first reply's {FormatHandle(handle)}; the tag changed mid-read.");
|
||||
data.AddRange(chunk.Span);
|
||||
lastChunk = chunk.Length;
|
||||
if (data.Count > MaxReadBytes)
|
||||
throw new AbTransportException(AbStatus.TooLarge,
|
||||
$"Fragmented read exceeded {MaxReadBytes} bytes; the controller kept answering Partial.");
|
||||
}
|
||||
Append(ref data, ref count, first.Span);
|
||||
var lastChunk = first.Length;
|
||||
|
||||
return (type, handle, data.ToArray());
|
||||
while (reply.GeneralStatus == StatusPartial)
|
||||
{
|
||||
if (lastChunk == 0)
|
||||
throw new AbTransportException(AbStatus.BadReply, "Partial read reply carried no data; the fragment loop cannot progress.");
|
||||
reply = await _scheduler.SendAsync(
|
||||
LogixServices.ReadTagFragmented(path, elements, (uint)count), ct,
|
||||
expectedReplyBytes: ExpectedReadReplyBytes(elements, elementBytes, count)).ConfigureAwait(false);
|
||||
var (fragmentType, fragmentHandle, chunk) = LogixServices.ParseReadReply(reply);
|
||||
if (fragmentType != type)
|
||||
throw new AbTransportException(AbStatus.BadReply,
|
||||
$"Read fragment type 0x{fragmentType:X4} differs from the first reply's 0x{type:X4}.");
|
||||
// A mid-read change of the structure handle means the tag's type changed under us (a
|
||||
// download / online edit); stitching the halves would yield a value of neither type.
|
||||
if (fragmentHandle != handle)
|
||||
throw new AbTransportException(AbStatus.BadReply,
|
||||
$"Read fragment structure handle {FormatHandle(fragmentHandle)} differs from the first reply's {FormatHandle(handle)}; the tag changed mid-read.");
|
||||
Append(ref data, ref count, chunk.Span);
|
||||
lastChunk = chunk.Length;
|
||||
if (count > MaxReadBytes)
|
||||
throw new AbTransportException(AbStatus.TooLarge,
|
||||
$"Fragmented read exceeded {MaxReadBytes} bytes; the controller kept answering Partial.");
|
||||
}
|
||||
|
||||
return (type, handle, data.AsSpan(0, count).ToArray());
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(data);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Appends <paramref name="chunk"/> to a pooled buffer, growing it (rent bigger, copy, return) as needed.</summary>
|
||||
private static void Append(ref byte[] buffer, ref int count, ReadOnlySpan<byte> chunk)
|
||||
{
|
||||
if (count + chunk.Length > buffer.Length)
|
||||
{
|
||||
var bigger = ArrayPool<byte>.Shared.Rent(Math.Max(buffer.Length * 2, count + chunk.Length));
|
||||
buffer.AsSpan(0, count).CopyTo(bigger);
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
buffer = bigger;
|
||||
}
|
||||
chunk.CopyTo(buffer.AsSpan(count));
|
||||
count += chunk.Length;
|
||||
}
|
||||
|
||||
private static string FormatHandle(ushort? handle) => handle is { } h ? $"0x{h:X4}" : "(none)";
|
||||
|
||||
@@ -113,7 +113,7 @@ public static class PcccServices
|
||||
public static ReadOnlyMemory<byte> ParseReply(CipReply reply, ushort expectedTns, byte[]? expectedRequestorId = null)
|
||||
{
|
||||
if (reply.GeneralStatus != 0)
|
||||
throw new AbTransportException(reply.Status, $"Execute PCCC CIP status 0x{reply.GeneralStatus:X2}.");
|
||||
throw new AbDeviceReplyException(reply.Status, $"Execute PCCC CIP status 0x{reply.GeneralStatus:X2}.");
|
||||
var r = new CipReader(reply.Data.Span);
|
||||
var idLen = r.U8();
|
||||
if (idLen < 1) throw new AbTransportException(AbStatus.BadReply, "Empty requestor id in PCCC reply.");
|
||||
@@ -131,7 +131,7 @@ public static class PcccServices
|
||||
throw new AbTransportException(AbStatus.BadReply, $"PCCC TNS {tns} != {expectedTns}.");
|
||||
byte? ext = sts == 0xF0 ? r.U8() : null;
|
||||
if (sts != 0)
|
||||
throw new AbTransportException(AbStatusCodes.FromPccc(sts, ext),
|
||||
throw new AbDeviceReplyException(AbStatusCodes.FromPccc(sts, ext),
|
||||
$"PCCC STS 0x{sts:X2}{(ext is { } e ? $" EXT 0x{e:X2}" : "")}.");
|
||||
return reply.Data[r.Position..];
|
||||
}
|
||||
|
||||
@@ -1,17 +1,38 @@
|
||||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
|
||||
|
||||
/// <summary>Growable little-endian writer for CIP / EIP frames.</summary>
|
||||
/// <remarks>
|
||||
/// R07:L5: the working buffer is rented from <see cref="ArrayPool{T}.Shared"/> (a 256-byte array per request used
|
||||
/// to be allocated and thrown away) and handed back by <see cref="ToArray"/>, which therefore COMPLETES the writer:
|
||||
/// the result is the caller's own array, a second <see cref="ToArray"/> returns another copy of it, and any further
|
||||
/// write throws <see cref="InvalidOperationException"/> (the buffer belongs to the pool again). A writer abandoned
|
||||
/// before <see cref="ToArray"/> (a builder that threw) simply leaves its buffer to the GC.
|
||||
/// </remarks>
|
||||
public sealed class CipWriter
|
||||
{
|
||||
private byte[] _buf = new byte[256];
|
||||
private const int InitialCapacity = 256;
|
||||
private byte[]? _buf = ArrayPool<byte>.Shared.Rent(InitialCapacity);
|
||||
private byte[]? _result;
|
||||
|
||||
/// <summary>Gets the number of bytes written so far.</summary>
|
||||
public int Length { get; private set; }
|
||||
|
||||
private byte[] Buffer => _buf ?? throw new InvalidOperationException(
|
||||
"This CipWriter was completed by ToArray(); its buffer has been returned to the pool.");
|
||||
|
||||
private Span<byte> Take(int n)
|
||||
{
|
||||
if (Length + n > _buf.Length) Array.Resize(ref _buf, Math.Max(_buf.Length * 2, Length + n));
|
||||
var s = _buf.AsSpan(Length, n);
|
||||
var buf = Buffer;
|
||||
if (Length + n > buf.Length)
|
||||
{
|
||||
var bigger = ArrayPool<byte>.Shared.Rent(Math.Max(buf.Length * 2, Length + n));
|
||||
buf.AsSpan(0, Length).CopyTo(bigger);
|
||||
ArrayPool<byte>.Shared.Return(buf);
|
||||
_buf = buf = bigger;
|
||||
}
|
||||
var s = buf.AsSpan(Length, n);
|
||||
Length += n;
|
||||
return s;
|
||||
}
|
||||
@@ -45,14 +66,27 @@ public sealed class CipWriter
|
||||
/// <param name="v">The replacement value.</param>
|
||||
/// <exception cref="ArgumentOutOfRangeException">The two bytes at <paramref name="offset"/> are not already
|
||||
/// written (an offset in the spare buffer capacity would otherwise be patched silently and lost).</exception>
|
||||
/// <exception cref="InvalidOperationException">The writer was completed by <see cref="ToArray"/>.</exception>
|
||||
public void PatchU16(int offset, ushort v)
|
||||
{
|
||||
var buf = Buffer;
|
||||
if (offset < 0 || offset > Length - 2)
|
||||
throw new ArgumentOutOfRangeException(nameof(offset), offset, $"Cannot patch 2 bytes at {offset}: only {Length} bytes written.");
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(_buf.AsSpan(offset, 2), v);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(offset, 2), v);
|
||||
}
|
||||
|
||||
/// <summary>Copies the written bytes to a new array.</summary>
|
||||
/// <returns>The bytes written so far.</returns>
|
||||
public byte[] ToArray() => _buf.AsSpan(0, Length).ToArray();
|
||||
/// <summary>
|
||||
/// Copies the written bytes to a new array and returns the working buffer to the pool, completing the writer
|
||||
/// (see the remarks on <see cref="CipWriter"/>).
|
||||
/// </summary>
|
||||
/// <returns>The bytes written, in an array the caller owns.</returns>
|
||||
public byte[] ToArray()
|
||||
{
|
||||
if (_result is { } done) return (byte[])done.Clone();
|
||||
var buf = Buffer;
|
||||
_result = buf.AsSpan(0, Length).ToArray();
|
||||
_buf = null;
|
||||
ArrayPool<byte>.Shared.Return(buf);
|
||||
return _result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// never writes <c>Degraded</c>, never opens the device-wide connect backoff, and warns once per tag. Everything
|
||||
/// else (timeout, bad connection, a reply that failed to parse, partial, abort, …) is a comms fault.
|
||||
/// <para>
|
||||
/// The native transport currently folds CIP general statuses 0x1C, 0x20 and 0xFF/0x2104 into
|
||||
/// <see cref="AbStatus.BadReply"/> (<c>AbStatusCodes.FromCip</c>, owned by <c>Driver.Ab.Transport</c>), which
|
||||
/// this classification cannot tell from a garbled reply; until the transport maps them explicitly they stay
|
||||
/// comms faults.
|
||||
/// The native transport maps CIP general statuses 0x1C (missing attribute list entry) and 0x20 (invalid
|
||||
/// parameter) to <see cref="AbStatus.Unsupported"/> and 0xFF/0x2104 (offset beyond end) to
|
||||
/// <see cref="AbStatus.OutOfBounds"/> (<c>AbStatusCodes.FromCip</c>, Lane K F1), so they classify here as
|
||||
/// device refusals. A Forward Open refused with one of them stays a connection failure.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class AbCipRefusals
|
||||
|
||||
@@ -789,9 +789,9 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now);
|
||||
return (OpVerdict.Neutral, null);
|
||||
}
|
||||
catch (AbTransportException ex) when (IsDeviceRefusal(ex.Status))
|
||||
catch (AbTransportException ex) when (IsAnsweredRefusal(ex))
|
||||
{
|
||||
// The native runtime raises a device's refusal (e.g. STS 0x20 "file not found") as an exception.
|
||||
// The native runtime raises a device's refusal (e.g. STS 0x50 "addressing problem") as an exception.
|
||||
WarnRefusalOnce(device, def, "read", ex.Message, AbLegacyStatusMapper.MapStatus(ex.Status));
|
||||
results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.MapStatus(ex.Status), null, now);
|
||||
return (OpVerdict.Answered, null);
|
||||
@@ -838,10 +838,10 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
/// reachability (backoff reset, <c>LastSuccessfulRead</c>, Healthy).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Inherits the native transport's PCCC STS table (<c>AbStatusCodes.FromPccc</c>, Lane K): it maps STS 0x30
|
||||
/// ("remote node missing" — a dead DH+/DH-485 node behind a bridge) and 0x20 to <c>NotAllowed</c>/<c>NotFound</c>,
|
||||
/// so on the NATIVE transport such a dead node still reads as answered. Follow-up F8 (review 07 r0 I4); this
|
||||
/// classification needs no change once it lands.
|
||||
/// Inherits the native transport's PCCC STS table (<c>AbStatusCodes.FromPccc</c>). Since Lane K F3 (F8, review
|
||||
/// 07 r0 I4) STS 0x20 / 0x30 / 0x90 — the host or a bridge reporting an unreachable END device — map to
|
||||
/// <see cref="AbStatus.BadConnection"/> (no reply), so a dead DH+/DH-485 node behind a bridge is a comms fault,
|
||||
/// not an answer (<c>NativePcccBridgeStatusHealthTests</c>).
|
||||
/// </remarks>
|
||||
/// <param name="status">The transport status.</param>
|
||||
/// <returns><see langword="true"/> for a device refusal.</returns>
|
||||
@@ -1035,7 +1035,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError);
|
||||
outcome.FaultMessage ??= tex.Message;
|
||||
}
|
||||
catch (AbTransportException ex) when (IsDeviceRefusal(ex.Status))
|
||||
catch (AbTransportException ex) when (IsAnsweredRefusal(ex))
|
||||
{
|
||||
device.RecordAnswered();
|
||||
outcome.AnyAnswered = true;
|
||||
@@ -1210,7 +1210,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
try { probeRuntime?.Dispose(); } catch { }
|
||||
probeRuntime = null;
|
||||
state.ProbeInitialized = false;
|
||||
if (ex is AbTransportException ate && IsDeviceRefusal(ate.Status)) state.RecordAnswered();
|
||||
if (IsAnsweredRefusal(ex)) state.RecordAnswered();
|
||||
else if (ex is not AbTransportException { Status: var st } || IsNoReply(st)) state.RecordCommsFault(Clock());
|
||||
}
|
||||
|
||||
@@ -1478,7 +1478,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
{
|
||||
return (null, AbLegacyStatusMapper.BadNotSupported, false, false);
|
||||
}
|
||||
catch (AbTransportException ex) when (IsDeviceRefusal(ex.Status))
|
||||
catch (AbTransportException ex) when (IsAnsweredRefusal(ex))
|
||||
{
|
||||
// The device answered: the index file/element does not exist. Per-tag, not a comms fault.
|
||||
return (null, AbLegacyStatusMapper.MapStatus(ex.Status), false, false);
|
||||
@@ -1617,10 +1617,26 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
|
||||
/// all PLC-5 writes), and libplctag's ErrorUnsupported maps to it too. It says nothing about the
|
||||
/// device's reachability, so it must not evict the handle or degrade driver health.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An Unsupported that came OFF THE WIRE (<see cref="AbDeviceReplyException"/>: CIP 0x08 / 0x1C / 0x20 on the
|
||||
/// Execute PCCC envelope, PCCC STS 0x10) is NOT local — the device answered — and is classified by
|
||||
/// <see cref="IsAnsweredRefusal"/> instead (review lane K I2).
|
||||
/// </remarks>
|
||||
/// <param name="ex">The exception to classify.</param>
|
||||
/// <returns><see langword="true"/> if the exception is a local refusal that never reached the wire.</returns>
|
||||
internal static bool IsLocalRefusal(Exception ex) =>
|
||||
ex is AbTransportException { Status: AbStatus.Unsupported };
|
||||
ex is AbTransportException { Status: AbStatus.Unsupported } and not AbDeviceReplyException;
|
||||
|
||||
/// <summary>
|
||||
/// True for an exception proving the device ANSWERED and refused this operation: a device-refusal status
|
||||
/// (<see cref="IsDeviceRefusal"/>), or an <see cref="AbStatus.Unsupported"/> that came off the wire
|
||||
/// (<see cref="AbDeviceReplyException"/>). Per-tag Bad, keeps the handle, advances <c>LastSuccessfulRead</c>.
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception to classify.</param>
|
||||
/// <returns><see langword="true"/> for an answered refusal.</returns>
|
||||
internal static bool IsAnsweredRefusal(Exception ex) =>
|
||||
ex is AbTransportException ate
|
||||
&& (IsDeviceRefusal(ate.Status) || ate is AbDeviceReplyException { Status: AbStatus.Unsupported });
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a freshly created runtime. A NATIVE runtime whose init (one read) is refused locally is
|
||||
|
||||
@@ -1603,7 +1603,8 @@ public sealed class ModbusDriver
|
||||
for (var i = 0; i < values.Length; i++)
|
||||
if (values[i]) bitmap[i / 8] |= (byte)(1 << (i % 8));
|
||||
var qty = (ushort)values.Length;
|
||||
var pdu15 = new byte[6 + 1 + byteCount];
|
||||
// [fc][addr:2][qty:2][byteCount] + data: exactly 6 + byteCount (no trailing byte past the declared data).
|
||||
var pdu15 = new byte[6 + byteCount];
|
||||
pdu15[0] = 0x0F;
|
||||
pdu15[1] = (byte)(tag.Address >> 8); pdu15[2] = (byte)(tag.Address & 0xFF);
|
||||
pdu15[3] = (byte)(qty >> 8); pdu15[4] = (byte)(qty & 0xFF);
|
||||
@@ -1638,7 +1639,8 @@ public sealed class ModbusDriver
|
||||
$"Write of {qty} registers to {tag.Name} exceeds MaxRegistersPerWrite={writeCap}. " +
|
||||
$"Split the tag (e.g. shorter StringLength or smaller ArrayCount) — partial FC16 chunks would lose atomicity.");
|
||||
if (await TryWriteViaFc23Async(transport, tag, bytes, qty, ct).ConfigureAwait(false)) return;
|
||||
var pdu = new byte[6 + 1 + bytes.Length];
|
||||
// [fc][addr:2][qty:2][byteCount] + data: exactly 6 + byteCount.
|
||||
var pdu = new byte[6 + bytes.Length];
|
||||
pdu[0] = 0x10;
|
||||
pdu[1] = (byte)(tag.Address >> 8); pdu[2] = (byte)(tag.Address & 0xFF);
|
||||
pdu[3] = (byte)(qty >> 8); pdu[4] = (byte)(qty & 0xFF);
|
||||
@@ -1685,7 +1687,8 @@ public sealed class ModbusDriver
|
||||
if (!_options.UseFC23ForMultiRegisterWrites || _options.DisableFC23
|
||||
|| qty < 2 || qty > Fc23MaxWriteRegisters || unsupported.ContainsKey(unit)) return false;
|
||||
|
||||
var pdu = new byte[10 + 1 + bytes.Length];
|
||||
// [fc][readAddr:2][readQty:2][writeAddr:2][writeQty:2][writeByteCount] + data: exactly 10 + byteCount.
|
||||
var pdu = new byte[10 + bytes.Length];
|
||||
pdu[0] = 0x17;
|
||||
pdu[1] = (byte)(tag.Address >> 8); pdu[2] = (byte)(tag.Address & 0xFF); // read start
|
||||
pdu[3] = (byte)(qty >> 8); pdu[4] = (byte)(qty & 0xFF); // read quantity
|
||||
@@ -1824,7 +1827,8 @@ public sealed class ModbusDriver
|
||||
payload[i * 2] = (byte)(reg >> 8);
|
||||
payload[i * 2 + 1] = (byte)(reg & 0xFF);
|
||||
}
|
||||
var pdu = new byte[6 + 1 + payload.Length];
|
||||
// [fc][addr:2][qty:2][byteCount] + data: exactly 6 + byteCount.
|
||||
var pdu = new byte[6 + payload.Length];
|
||||
pdu[0] = 0x10;
|
||||
pdu[1] = (byte)(tag.Address >> 8); pdu[2] = (byte)(tag.Address & 0xFF);
|
||||
pdu[3] = (byte)(count >> 8); pdu[4] = (byte)(count & 0xFF);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Buffers;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
@@ -55,12 +56,33 @@ public static class ModbusRtuFraming
|
||||
/// <returns>A new array: <c>[unitId, ...pdu, crcLo, crcHi]</c>.</returns>
|
||||
public static byte[] BuildAdu(byte unitId, ReadOnlySpan<byte> pdu)
|
||||
{
|
||||
var frame = new byte[1 + pdu.Length];
|
||||
frame[0] = unitId;
|
||||
pdu.CopyTo(frame.AsSpan(1));
|
||||
return ModbusCrc.Append(frame);
|
||||
var frame = new byte[AduLength(pdu.Length)];
|
||||
WriteAdu(unitId, pdu, frame);
|
||||
return frame;
|
||||
}
|
||||
|
||||
/// <summary>The length of the RTU ADU for a PDU of <paramref name="pduLength"/> bytes (unit id + PDU + CRC).</summary>
|
||||
/// <param name="pduLength">The bare PDU length.</param>
|
||||
/// <returns>The ADU length.</returns>
|
||||
internal static int AduLength(int pduLength) => 1 + pduLength + 2;
|
||||
|
||||
/// <summary>Writes the RTU ADU into <paramref name="destination"/> (exactly <see cref="AduLength"/> bytes).</summary>
|
||||
/// <param name="unitId">The RTU slave/unit address.</param>
|
||||
/// <param name="pdu">The bare PDU.</param>
|
||||
/// <param name="destination">The ADU buffer, exactly <see cref="AduLength"/>(<paramref name="pdu"/>.Length) long.</param>
|
||||
internal static void WriteAdu(byte unitId, ReadOnlySpan<byte> pdu, Span<byte> destination)
|
||||
{
|
||||
destination[0] = unitId;
|
||||
pdu.CopyTo(destination[1..]);
|
||||
var body = destination[..(1 + pdu.Length)];
|
||||
var crc = ModbusCrc.Compute(body);
|
||||
destination[1 + pdu.Length] = (byte)(crc & 0xFF); // CRC low byte first
|
||||
destination[2 + pdu.Length] = (byte)(crc >> 8);
|
||||
}
|
||||
|
||||
/// <summary>The largest RTU response frame: addr + fc + byteCount + 255 data bytes + CRC.</summary>
|
||||
private const int MaxResponseFrame = 3 + 255 + 2;
|
||||
|
||||
/// <summary>
|
||||
/// Reads a single RTU response frame from <paramref name="stream"/> and returns its bare PDU
|
||||
/// (<c>[fc, ...data]</c>), with the leading unit-id and trailing CRC stripped.
|
||||
@@ -82,58 +104,56 @@ public static class ModbusRtuFraming
|
||||
public static async Task<byte[]> ReadResponsePduAsync(
|
||||
Stream stream, byte expectedUnit, CancellationToken ct)
|
||||
{
|
||||
// Header: addr(1) + fc(1). The function code selects how many more bytes to read.
|
||||
var header = new byte[2];
|
||||
await ReadExactlyAsync(stream, header, ct).ConfigureAwait(false);
|
||||
var fc = header[1];
|
||||
// R07:L5: the whole frame is read into ONE pooled buffer and validated in place; only the returned PDU is
|
||||
// allocated (it used to be five arrays: header, byteCount, rest, the reassembled frame and the PDU).
|
||||
var frame = ArrayPool<byte>.Shared.Rent(MaxResponseFrame);
|
||||
try
|
||||
{
|
||||
// Header: addr(1) + fc(1). The function code selects how many more bytes to read.
|
||||
await ReadExactlyAsync(stream, frame.AsMemory(0, 2), ct).ConfigureAwait(false);
|
||||
var fc = frame[1];
|
||||
|
||||
int trailing; // bytes after addr+fc, up to and including the 2 CRC bytes.
|
||||
if ((fc & 0x80) != 0)
|
||||
{
|
||||
// Exception frame: excCode(1) + CRC(2).
|
||||
trailing = 1 + 2;
|
||||
}
|
||||
else if (fc is 0x01 or 0x02 or 0x03 or 0x04 or 0x17)
|
||||
{
|
||||
// Read response (and FC23 Read/Write Multiple, whose reply is a read): byteCount(1) then
|
||||
// byteCount data bytes + CRC(2).
|
||||
var bc = new byte[1];
|
||||
await ReadExactlyAsync(stream, bc, ct).ConfigureAwait(false);
|
||||
var byteCount = bc[0];
|
||||
var rest = new byte[byteCount + 2];
|
||||
await ReadExactlyAsync(stream, rest, ct).ConfigureAwait(false);
|
||||
return ValidateAndStrip(expectedUnit, header, bc, rest);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fixed 4-byte echo: correct for the write FCs this driver emits (05/06/0F/10). FC23 is
|
||||
// variable-length and handled above; any NEW variable-length response FC must be added there
|
||||
// or it is mis-sized here and surfaces as a desync.
|
||||
trailing = 4 + 2;
|
||||
}
|
||||
int length; // the whole frame, addr through CRC
|
||||
var already = 2; // bytes of the frame read so far
|
||||
if ((fc & 0x80) != 0)
|
||||
{
|
||||
// Exception frame: excCode(1) + CRC(2).
|
||||
length = 2 + 1 + 2;
|
||||
}
|
||||
else if (fc is 0x01 or 0x02 or 0x03 or 0x04 or 0x17)
|
||||
{
|
||||
// Read response (and FC23 Read/Write Multiple, whose reply is a read): byteCount(1) then
|
||||
// byteCount data bytes + CRC(2).
|
||||
await ReadExactlyAsync(stream, frame.AsMemory(2, 1), ct).ConfigureAwait(false);
|
||||
length = 3 + frame[2] + 2;
|
||||
already = 3;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fixed 4-byte echo: correct for the write FCs this driver emits (05/06/0F/10). FC23 is
|
||||
// variable-length and handled above; any NEW variable-length response FC must be added there
|
||||
// or it is mis-sized here and surfaces as a desync.
|
||||
length = 2 + 4 + 2;
|
||||
}
|
||||
|
||||
var tail = new byte[trailing];
|
||||
await ReadExactlyAsync(stream, tail, ct).ConfigureAwait(false);
|
||||
return ValidateAndStrip(expectedUnit, header, ReadOnlySpan<byte>.Empty, tail);
|
||||
await ReadExactlyAsync(stream, frame.AsMemory(already, length - already), ct).ConfigureAwait(false);
|
||||
return ValidateAndStrip(expectedUnit, frame.AsSpan(0, length));
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(frame);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assembles the full frame from its pieces, validates the trailing CRC over everything
|
||||
/// except the CRC bytes, then validates the response address against
|
||||
/// <paramref name="expectedUnit"/> and strips <c>addr</c> + <c>CRC</c> to return the bare PDU.
|
||||
/// Validates the trailing CRC over everything except the CRC bytes, then validates the response address
|
||||
/// against <paramref name="expectedUnit"/> and strips <c>addr</c> + <c>CRC</c> to return the bare PDU.
|
||||
/// A CRC-valid exception PDU throws <see cref="ModbusException"/>.
|
||||
/// </summary>
|
||||
private static byte[] ValidateAndStrip(
|
||||
byte expectedUnit, ReadOnlySpan<byte> header, ReadOnlySpan<byte> middle, ReadOnlySpan<byte> tail)
|
||||
private static byte[] ValidateAndStrip(byte expectedUnit, ReadOnlySpan<byte> frame)
|
||||
{
|
||||
// Reassemble the whole frame: header(addr,fc) + middle(optional byteCount) + tail.
|
||||
var frame = new byte[header.Length + middle.Length + tail.Length];
|
||||
header.CopyTo(frame);
|
||||
middle.CopyTo(frame.AsSpan(header.Length));
|
||||
tail.CopyTo(frame.AsSpan(header.Length + middle.Length));
|
||||
|
||||
// CRC covers everything except the trailing 2 CRC bytes.
|
||||
var body = frame.AsSpan(0, frame.Length - 2);
|
||||
var body = frame[..^2];
|
||||
var expected = ModbusCrc.Compute(body);
|
||||
var actual = (ushort)(frame[^2] | (frame[^1] << 8));
|
||||
if (actual != expected)
|
||||
@@ -172,12 +192,12 @@ public static class ModbusRtuFraming
|
||||
/// <see cref="ModbusTransportDesyncException"/> so a length-less RTU frame that arrives
|
||||
/// short is classified as a desync rather than a bare <see cref="EndOfStreamException"/>.
|
||||
/// </summary>
|
||||
private static async Task ReadExactlyAsync(Stream stream, byte[] buf, CancellationToken ct)
|
||||
private static async Task ReadExactlyAsync(Stream stream, Memory<byte> buf, CancellationToken ct)
|
||||
{
|
||||
var read = 0;
|
||||
while (read < buf.Length)
|
||||
{
|
||||
var n = await stream.ReadAsync(buf.AsMemory(read), ct).ConfigureAwait(false);
|
||||
var n = await stream.ReadAsync(buf[read..], ct).ConfigureAwait(false);
|
||||
if (n == 0)
|
||||
throw new ModbusTransportDesyncException(
|
||||
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Buffers;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
@@ -150,14 +151,17 @@ public sealed class ModbusRtuOverTcpTransport : IModbusTransport
|
||||
var stream = CurrentStream;
|
||||
if (stream is null) throw new InvalidOperationException("Transport not connected");
|
||||
|
||||
// RTU ADU: [unitId] + PDU + CRC — no MBAP header, no TxId.
|
||||
var adu = ModbusRtuFraming.BuildAdu(unitId, pdu);
|
||||
// RTU ADU: [unitId] + PDU + CRC — no MBAP header, no TxId. Built in a pooled buffer (R07:L5); the same bytes
|
||||
// ModbusRtuFraming.BuildAdu produces (pinned by a test).
|
||||
var aduLength = ModbusRtuFraming.AduLength(pdu.Length);
|
||||
var adu = ArrayPool<byte>.Shared.Rent(aduLength);
|
||||
ModbusRtuFraming.WriteAdu(unitId, pdu, adu.AsSpan(0, aduLength));
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(_timeout);
|
||||
try
|
||||
{
|
||||
await stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
|
||||
await stream.WriteAsync(adu.AsMemory(0, aduLength), cts.Token).ConfigureAwait(false);
|
||||
await stream.FlushAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
// Framing owns the length-less RTU deframe + CRC + unit-address validation. A CRC-valid
|
||||
@@ -181,6 +185,11 @@ public sealed class ModbusRtuOverTcpTransport : IModbusTransport
|
||||
ModbusTransportDesyncException.DesyncReason.Timeout,
|
||||
$"Modbus per-op response timeout after {_timeout.TotalMilliseconds:0}ms");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// The write has completed, or was cancelled and the stream torn down, so nothing still reads it.
|
||||
ArrayPool<byte>.Shared.Return(adu);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Asynchronously disposes the transport and underlying socket resources.</summary>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Buffers;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
@@ -59,6 +60,10 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
/// <summary>The socket lifecycle this transport composes (test seam for its clock).</summary>
|
||||
internal ModbusSocketLifecycle Lifecycle => _life;
|
||||
|
||||
// R07:L5: the 7-byte MBAP header of the reply being read. SendOnceAsync runs only under _gate (single-flight), so
|
||||
// one buffer per transport serves every transaction (it used to be a fresh array per reply).
|
||||
private readonly byte[] _header = new byte[7];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ConnectAsync(CancellationToken ct) => _life.ConnectAsync(ct);
|
||||
|
||||
@@ -139,11 +144,16 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
if (stream is null) throw new InvalidOperationException("Transport not connected");
|
||||
var txId = ++_nextTx;
|
||||
|
||||
// MBAP: [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU
|
||||
var adu = new byte[7 + pdu.Length];
|
||||
// MBAP: [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU. Built in a pooled buffer (R07:L5): the request
|
||||
// never escapes, and the write has completed (or been cancelled and the socket torn down) before the finally
|
||||
// hands it back. Only the reply PDU, which the caller keeps, is allocated.
|
||||
// (One async method on purpose: a second awaited helper costs a state-machine box per call, which ate the saving.)
|
||||
var aduLength = 7 + pdu.Length;
|
||||
var adu = ArrayPool<byte>.Shared.Rent(aduLength);
|
||||
adu[0] = (byte)(txId >> 8);
|
||||
adu[1] = (byte)(txId & 0xFF);
|
||||
// protocol id already zero
|
||||
adu[2] = 0; // protocol id
|
||||
adu[3] = 0;
|
||||
var len = (ushort)(1 + pdu.Length); // unit id + pdu
|
||||
adu[4] = (byte)(len >> 8);
|
||||
adu[5] = (byte)(len & 0xFF);
|
||||
@@ -154,10 +164,10 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
cts.CancelAfter(_timeout);
|
||||
try
|
||||
{
|
||||
await stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
|
||||
await stream.WriteAsync(adu.AsMemory(0, aduLength), cts.Token).ConfigureAwait(false);
|
||||
await stream.FlushAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
var header = new byte[7];
|
||||
var header = _header;
|
||||
await ReadExactlyAsync(stream, header, cts.Token).ConfigureAwait(false);
|
||||
var respTxId = (ushort)((header[0] << 8) | header[1]);
|
||||
if (respTxId != txId)
|
||||
@@ -211,6 +221,11 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
ModbusTransportDesyncException.DesyncReason.Timeout,
|
||||
$"Modbus per-op response timeout after {_timeout.TotalMilliseconds:0}ms");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// The write has completed, or was cancelled and the socket torn down, so nothing still reads it.
|
||||
ArrayPool<byte>.Shared.Return(adu);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReadExactlyAsync(Stream s, byte[] buf, CancellationToken ct)
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis
|
||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||
@inject NavigationManager Nav
|
||||
|
||||
@@ -31,7 +32,9 @@ else if (!IsNew && _existing is null)
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="scriptEdit">
|
||||
@* OnSubmit (not OnValidSubmit): the editor text is pulled from Monaco FIRST, then validated — the bound
|
||||
SourceCode is only refreshed at blur, so validating before the pull could judge stale text. *@
|
||||
<EditForm Model="_form" OnSubmit="SubmitAsync" FormName="scriptEdit">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="alert alert-danger py-2 px-3" />
|
||||
<section class="panel rise">
|
||||
@@ -58,7 +61,9 @@ else
|
||||
<section class="panel rise mt-3">
|
||||
<div class="panel-head">Source</div>
|
||||
<div style="padding:1rem">
|
||||
<MonacoEditor @bind-Value="_form.SourceCode" Height="420px" />
|
||||
<MonacoEditor @ref="_editor" @bind-Value="_form.SourceCode" Height="420px"
|
||||
MarkersChanged="OnMarkersChanged" />
|
||||
<ScriptProblemsList Report="_markers" OnSelect="RevealProblemAsync" />
|
||||
<div class="form-text">SHA-256 hash is computed automatically on save.</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -91,6 +96,13 @@ else
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
private bool _confirmDeleteVisible;
|
||||
private MonacoEditor? _editor;
|
||||
private ScriptProblemReport _markers = ScriptProblemReport.Empty;
|
||||
|
||||
private void OnMarkersChanged(ScriptProblemReport report) => _markers = report;
|
||||
|
||||
private Task RevealProblemAsync(DiagnosticMarker m)
|
||||
=> _editor is null ? Task.CompletedTask : _editor.RevealLineAsync(m.StartLineNumber, m.StartColumn);
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -112,11 +124,21 @@ else
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
private async Task SubmitAsync(EditContext editContext)
|
||||
{
|
||||
_busy = true; _error = null;
|
||||
try
|
||||
{
|
||||
// The editor reports its text only at blur, so read the authoritative current value now. A read
|
||||
// failure (e.g. the script is too large to send) aborts the save rather than persisting stale source.
|
||||
if (_editor is not null)
|
||||
{
|
||||
var read = await _editor.GetValueAsync();
|
||||
if (!read.Ok) { _error = $"Not saved: {read.Error}"; return; }
|
||||
_form.SourceCode = read.Value!;
|
||||
}
|
||||
if (!editContext.Validate()) return;
|
||||
|
||||
var sourceHash = HashSource(_form.SourceCode);
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (IsNew)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared
|
||||
@using Microsoft.Extensions.Logging
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis
|
||||
@implements IAsyncDisposable
|
||||
@inject IJSRuntime JS
|
||||
@inject Microsoft.Extensions.Logging.ILogger<MonacoEditor> Logger
|
||||
@@ -20,9 +21,23 @@
|
||||
|
||||
<div @ref="_hostRef" class="monaco-editor-host"
|
||||
style="height: @Height; border: 1px solid var(--bs-border-color); border-radius: 0.25rem; overflow: hidden;"></div>
|
||||
@if (_tooLargeBytes > 0)
|
||||
{
|
||||
@* Non-blocking: the author keeps editing, but learns now (not at Save) that this text cannot be sent. *@
|
||||
<div class="text-warning small mt-1" role="status">
|
||||
This script is too large to send to the server (@_tooLargeBytes bytes; the limit is
|
||||
@ScriptEditorLimits.MaxEditorSyncBytes bytes). It cannot be saved until it is shortened.
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// The editor text. Two-way bound, but the editor reports edits back only when it loses focus — never per
|
||||
/// keystroke (each report is one circuit message carrying the whole script). A page that persists the text
|
||||
/// must read the authoritative current value with <see cref="GetValueAsync"/> at save time.
|
||||
/// </summary>
|
||||
[Parameter] public string Value { get; set; } = "";
|
||||
/// <summary>Raised with the editor text when the editor loses focus after an edit (and from <see cref="GetValueAsync"/>).</summary>
|
||||
[Parameter] public EventCallback<string> ValueChanged { get; set; }
|
||||
[Parameter] public string Language { get; set; } = "csharp";
|
||||
[Parameter] public string Height { get; set; } = "320px";
|
||||
@@ -37,17 +52,20 @@
|
||||
[Parameter] public string? EquipmentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Fires whenever Monaco's marker set updates (after the 500 ms diagnostic
|
||||
/// debounce). The marker DTO is not modelled yet — typed as object[] until a
|
||||
/// later task wires the Roslyn-backed diagnostics.
|
||||
/// Fires whenever Monaco's diagnostic marker set updates (after the 500 ms debounce, and only for the response
|
||||
/// to the CURRENT text — superseded responses are dropped in the JS). Feeds <c>ScriptProblemsList</c>; the
|
||||
/// markers are sent from JS only when this callback has a subscriber, and at most
|
||||
/// <see cref="ScriptEditorLimits.MaxReportedMarkers"/> of them (plus the total) so the report always fits in one
|
||||
/// circuit message.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback<object[]> MarkersChanged { get; set; }
|
||||
[Parameter] public EventCallback<ScriptProblemReport> MarkersChanged { get; set; }
|
||||
|
||||
private ElementReference _hostRef;
|
||||
private DotNetObjectReference<MonacoEditor>? _dotNetRef;
|
||||
private readonly string _id = Guid.NewGuid().ToString("N");
|
||||
private string _lastSentValue = "";
|
||||
private bool _initialized;
|
||||
private int _tooLargeBytes;
|
||||
private bool _wrap;
|
||||
private bool _minimap;
|
||||
private bool _dark;
|
||||
@@ -69,7 +87,11 @@
|
||||
value = Value ?? "",
|
||||
language = Language,
|
||||
readOnly = ReadOnly,
|
||||
equipmentId = EquipmentId
|
||||
equipmentId = EquipmentId,
|
||||
maxSyncBytes = ScriptEditorLimits.MaxEditorSyncBytes,
|
||||
reportMarkers = MarkersChanged.HasDelegate,
|
||||
maxReportedMarkers = ScriptEditorLimits.MaxReportedMarkers,
|
||||
maxReportedMessageChars = ScriptEditorLimits.MaxReportedMessageChars
|
||||
},
|
||||
_dotNetRef);
|
||||
_initialized = true;
|
||||
@@ -126,9 +148,57 @@
|
||||
return ValueChanged.InvokeAsync(_lastSentValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the editor's current text on demand (call it at Save), raises <see cref="ValueChanged"/> when it
|
||||
/// differs from the last value reported, and returns it. Before the editor is live (prerender, failed init)
|
||||
/// the bound <see cref="Value"/> is authoritative and is returned as-is.
|
||||
/// </summary>
|
||||
/// <returns>The current text, or an error (e.g. the script is too large to send) — never a stale value.</returns>
|
||||
public async Task<EditorReadResult> GetValueAsync()
|
||||
{
|
||||
if (!_initialized) return new EditorReadResult(Value ?? "", null);
|
||||
string? current;
|
||||
try
|
||||
{
|
||||
current = await JS.InvokeAsync<string?>("MonacoBlazor.getValue", _id);
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
return new EditorReadResult(null, "The connection to the editor was lost; reload the page and try again.");
|
||||
}
|
||||
catch (JSException ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Monaco editor {EditorId}: failed to read the editor text.", _id);
|
||||
// JSException.Message carries the JS stack after the first line; the author needs only the reason.
|
||||
return new EditorReadResult(null, ex.Message.Split('\n', 2)[0].Trim());
|
||||
}
|
||||
if (current is null) return new EditorReadResult(Value ?? "", null); // editor already disposed
|
||||
_tooLargeBytes = 0; // it was read, so it fits
|
||||
await OnValueChanged(current);
|
||||
return new EditorReadResult(current, null);
|
||||
}
|
||||
|
||||
/// <summary>The outcome of <see cref="GetValueAsync"/>: the text, or why it could not be read.</summary>
|
||||
/// <param name="Value">The editor text; null when <paramref name="Error"/> is set.</param>
|
||||
/// <param name="Error">Why the text could not be read; null on success.</param>
|
||||
public sealed record EditorReadResult(string? Value, string? Error)
|
||||
{
|
||||
/// <summary>True when <see cref="Value"/> holds the editor text.</summary>
|
||||
public bool Ok => Error is null;
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public Task OnMarkersChanged(object[] markers) =>
|
||||
MarkersChanged.InvokeAsync(markers ?? Array.Empty<object>());
|
||||
public Task OnMarkersChanged(DiagnosticMarker[]? markers, int total) =>
|
||||
MarkersChanged.InvokeAsync(ScriptProblemReport.FromEditor(markers, total));
|
||||
|
||||
/// <summary>The JS reports, at blur, that the text has grown past (bytes > 0) or back under (0) the sync limit.</summary>
|
||||
[JSInvokable]
|
||||
public void OnSyncRefused(int bytes)
|
||||
{
|
||||
if (_tooLargeBytes == bytes) return;
|
||||
_tooLargeBytes = bytes;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>Programmatic scroll-to-line (called by the problems panel).</summary>
|
||||
public async Task RevealLineAsync(int line, int column = 1)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
@namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis
|
||||
@* The script editor's Problems list. Fed by MonacoEditor.MarkersChanged; clicking a row calls OnSelect, which the
|
||||
host wires to MonacoEditor.RevealLineAsync so the caret jumps to the problem. Ordering and wording live in
|
||||
ScriptProblems (unit-tested). *@
|
||||
|
||||
@{
|
||||
var report = Report ?? ScriptProblemReport.Empty;
|
||||
var ordered = ScriptProblems.Order(report.Markers);
|
||||
}
|
||||
<div class="script-problems mt-2 small" role="region" aria-label="Problems">
|
||||
<div class="d-flex align-items-center gap-2 text-muted mb-1">
|
||||
<span class="fw-semibold">Problems</span>
|
||||
<span>@ScriptProblems.Summary(report with { Markers = ordered })</span>
|
||||
</div>
|
||||
@if (ordered.Count > 0)
|
||||
{
|
||||
<ul class="list-group list-group-flush border rounded" style="max-height: 10rem; overflow-y: auto;">
|
||||
@foreach (var m in ordered.Take(MaxRows))
|
||||
{
|
||||
<li class="list-group-item list-group-item-action py-1 px-2 d-flex gap-2 align-items-baseline"
|
||||
role="button" tabindex="0" style="cursor: pointer;"
|
||||
title="Go to line @m.StartLineNumber, column @m.StartColumn"
|
||||
@onclick="() => OnSelect.InvokeAsync(m)"
|
||||
@onkeydown="e => OnKeyAsync(e, m)">
|
||||
<span class="badge @BadgeClass(m.Severity)">@ScriptProblems.SeverityLabel(m.Severity)</span>
|
||||
<span class="mono text-muted">@m.StartLineNumber:@m.StartColumn</span>
|
||||
<span class="flex-grow-1">@m.Message</span>
|
||||
<span class="mono text-muted">@m.Code</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@if (report.Total > Math.Min(ordered.Count, MaxRows))
|
||||
{
|
||||
@* The editor reports at most ScriptEditorLimits.MaxReportedMarkers problems (one circuit message); the
|
||||
rest are still squiggled in the editor. *@
|
||||
<div class="text-muted mt-1">+@(report.Total - Math.Min(ordered.Count, MaxRows)) more — fix the problems above, or scroll the editor to see them all.</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private const int MaxRows = ScriptEditorLimits.MaxReportedMarkers;
|
||||
|
||||
/// <summary>The report the editor last sent (null before the first diagnostics pass).</summary>
|
||||
[Parameter] public ScriptProblemReport? Report { get; set; }
|
||||
|
||||
/// <summary>Raised when the author picks a problem; wire it to <c>MonacoEditor.RevealLineAsync</c>.</summary>
|
||||
[Parameter] public EventCallback<DiagnosticMarker> OnSelect { get; set; }
|
||||
|
||||
private Task OnKeyAsync(KeyboardEventArgs e, DiagnosticMarker m)
|
||||
=> e.Key is "Enter" or " " ? OnSelect.InvokeAsync(m) : Task.CompletedTask;
|
||||
|
||||
private static string BadgeClass(int severity) => severity switch
|
||||
{
|
||||
ScriptProblems.Error => "text-bg-danger",
|
||||
ScriptProblems.Warning => "text-bg-warning",
|
||||
_ => "text-bg-secondary",
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis
|
||||
@inject IUnsTreeService Svc
|
||||
|
||||
@if (Visible)
|
||||
@@ -99,7 +100,9 @@
|
||||
Editing shared script "<span class="mono">@_scriptName</span>" — used by
|
||||
@_scriptUsageCount virtual tag(s). Changes affect all of them.
|
||||
</div>
|
||||
<MonacoEditor @bind-Value="_scriptSource" Height="300px" EquipmentId="@EquipmentId" />
|
||||
<MonacoEditor @ref="_scriptEditor" @bind-Value="_scriptSource" Height="300px" EquipmentId="@EquipmentId"
|
||||
MarkersChanged="OnScriptMarkersChanged" />
|
||||
<ScriptProblemsList Report="_scriptMarkers" OnSelect="RevealScriptProblemAsync" />
|
||||
@if (!string.IsNullOrWhiteSpace(_scriptError))
|
||||
{
|
||||
<div class="text-danger small mt-2">@_scriptError</div>
|
||||
@@ -217,6 +220,24 @@
|
||||
private bool _scriptSaving;
|
||||
private bool _scriptSaved;
|
||||
private string? _scriptError;
|
||||
private MonacoEditor? _scriptEditor;
|
||||
private ScriptProblemReport _scriptMarkers = ScriptProblemReport.Empty;
|
||||
|
||||
private void OnScriptMarkersChanged(ScriptProblemReport report) => _scriptMarkers = report;
|
||||
|
||||
private Task RevealScriptProblemAsync(DiagnosticMarker m)
|
||||
=> _scriptEditor is null ? Task.CompletedTask : _scriptEditor.RevealLineAsync(m.StartLineNumber, m.StartColumn);
|
||||
|
||||
// The inline editor reports its text only at blur; pull the authoritative current value before acting on it.
|
||||
// Returns false (with _scriptError set) when the text could not be read, so nothing stale is saved.
|
||||
private async Task<bool> PullScriptSourceAsync()
|
||||
{
|
||||
if (_scriptEditor is null) return true;
|
||||
var read = await _scriptEditor.GetValueAsync();
|
||||
if (!read.Ok) { _scriptError = $"Not saved: {read.Error}"; return false; }
|
||||
_scriptSource = read.Value!;
|
||||
return true;
|
||||
}
|
||||
|
||||
// "New script" action state (shown only when no script is bound yet).
|
||||
private bool _scriptCreating;
|
||||
@@ -227,6 +248,9 @@
|
||||
if (!Visible)
|
||||
{
|
||||
_loadedKey = null; // closed → next open reloads fresh
|
||||
// Closing removes the editor component: drop its reference and its problems.
|
||||
_scriptEditor = null;
|
||||
_scriptMarkers = ScriptProblemReport.Empty;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -278,6 +302,9 @@
|
||||
{
|
||||
_scriptSaved = false;
|
||||
_scriptError = null;
|
||||
// A different script (or a reload) — drop the previous script's problems. The editor instance itself stays:
|
||||
// while the panel is open the same MonacoEditor component receives the new source via its Value.
|
||||
_scriptMarkers = ScriptProblemReport.Empty;
|
||||
|
||||
if (string.IsNullOrEmpty(_form.ScriptId))
|
||||
{
|
||||
@@ -358,8 +385,16 @@
|
||||
/// Toggles the inline script-source panel. Clears the stale "Script saved." banner when
|
||||
/// (re)expanding so a collapse/expand cycle doesn't re-show a confirmation from an earlier save.
|
||||
/// </summary>
|
||||
private void ToggleScriptPanel()
|
||||
private async Task ToggleScriptPanel()
|
||||
{
|
||||
if (_scriptExpanded)
|
||||
{
|
||||
// Collapsing disposes the editor: keep its unsaved text in _scriptSource so re-opening shows it.
|
||||
// A failed read keeps the panel open (with the error) rather than discarding the edits.
|
||||
if (!await PullScriptSourceAsync()) return;
|
||||
_scriptEditor = null;
|
||||
_scriptMarkers = ScriptProblemReport.Empty;
|
||||
}
|
||||
_scriptExpanded = !_scriptExpanded;
|
||||
if (_scriptExpanded)
|
||||
{
|
||||
@@ -384,6 +419,7 @@
|
||||
_scriptError = null;
|
||||
try
|
||||
{
|
||||
if (!await PullScriptSourceAsync()) return;
|
||||
var result = await Svc.UpdateScriptSourceAsync(_form.ScriptId, _scriptSource, _scriptRowVersion);
|
||||
if (result.Ok)
|
||||
{
|
||||
|
||||
@@ -57,7 +57,7 @@ public static class EndpointRouteBuilderExtensions
|
||||
/// <returns>The same service collection, for chaining.</returns>
|
||||
public static IServiceCollection AddAdminUI(this IServiceCollection services)
|
||||
{
|
||||
services.AddRazorComponents().AddInteractiveServerComponents();
|
||||
services.AddRazorComponents().AddInteractiveServerComponents().AddHubOptions(o => o.MaximumReceiveMessageSize = ScriptAnalysis.ScriptEditorLimits.HubMaximumReceiveMessageSize);
|
||||
services.AddOtOpcUaDriverStatusServices();
|
||||
|
||||
// Secrets-management UI (ZB.MOM.WW.Secrets.Ui RCL, mounted at /admin/secrets). Register its
|
||||
|
||||
@@ -105,31 +105,20 @@ public sealed class ScriptTagCatalog(
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct)
|
||||
{
|
||||
var entries = await cache.GetOrBuildAsync(BuildEntriesAsync, ct);
|
||||
|
||||
var paths = new HashSet<string>(entries.Select(e => e.Path), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
IEnumerable<string> query = paths;
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
query = query.Where(p => p.StartsWith(filter, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return query
|
||||
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(MaxResults)
|
||||
.ToList();
|
||||
// The sorted, de-duplicated path array is built once per snapshot; a keystroke is a binary search.
|
||||
var index = await cache.GetOrBuildIndexAsync(BuildEntriesAsync, ct);
|
||||
return index.FindByPrefix(filter, MaxResults);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
|
||||
{
|
||||
var entries = await cache.GetOrBuildAsync(BuildEntriesAsync, ct);
|
||||
var index = await cache.GetOrBuildIndexAsync(BuildEntriesAsync, ct);
|
||||
|
||||
// Exact, case-SENSITIVE (Ordinal) match: the live runtime resolves a ctx.GetTag("X") literal
|
||||
// via DependencyMuxActor keyed with StringComparer.Ordinal, so a case-mismatched path would
|
||||
// not resolve at runtime — hover must report it as "not a known configured tag path".
|
||||
return entries.FirstOrDefault(e => string.Equals(e.Path, path, StringComparison.Ordinal));
|
||||
return index.TryGet(path);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -22,7 +22,3 @@ public record SignatureHelpParameter(string Label, string? Documentation);
|
||||
|
||||
public record FormatRequest(string Code);
|
||||
public record FormatResponse(string Code);
|
||||
|
||||
public record InlayHintsRequest(string Code);
|
||||
public record InlayHintsResponse(IReadOnlyList<InlayHint> Hints);
|
||||
public record InlayHint(int Line, int Column, string Label);
|
||||
|
||||
@@ -8,20 +8,37 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||
|
||||
public static class ScriptAnalysisEndpoints
|
||||
{
|
||||
/// <summary>Status returned when the editor abandoned the request (Monaco cancelled it or the fetch was
|
||||
/// aborted). Nobody reads the body; it only keeps a routine cancellation out of the error logs.</summary>
|
||||
private const int ClientClosedRequest = 499;
|
||||
|
||||
/// <summary>Maps the <c>/api/script-analysis</c> endpoint group (diagnostics, completions, hover, signature help, format), gated by the ConfigEditor policy (Administrator or Designer).</summary>
|
||||
/// <param name="endpoints">The route builder to map the endpoints onto.</param>
|
||||
/// <returns>The same route builder, for chaining.</returns>
|
||||
/// <remarks>Every analysis handler binds the request's <see cref="CancellationToken"/> (<c>HttpContext.RequestAborted</c>)
|
||||
/// and passes it to the analysis, so a request the editor abandoned stops compiling instead of running to completion.</remarks>
|
||||
public static IEndpointRouteBuilder MapScriptAnalysisEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
// ConfigEditor policy (Administrator or Designer) — matches the Script page gate and the /deployments gate.
|
||||
var group = endpoints.MapGroup("/api/script-analysis")
|
||||
.RequireAuthorization(AdminUiPolicies.ConfigEditor);
|
||||
group.MapPost("/diagnostics", async (DiagnoseRequest r, ScriptAnalysisService s) => Results.Ok(await s.DiagnoseAsync(r)));
|
||||
group.MapPost("/completions", async (CompletionsRequest r, ScriptAnalysisService s) => Results.Ok(await s.CompleteAsync(r)));
|
||||
group.MapPost("/hover", async (HoverRequest r, ScriptAnalysisService s) => Results.Ok(await s.Hover(r)));
|
||||
group.MapPost("/signature-help", (SignatureHelpRequest r, ScriptAnalysisService s) => Results.Ok(s.SignatureHelp(r)));
|
||||
group.MapPost("/diagnostics", (DiagnoseRequest r, ScriptAnalysisService s, CancellationToken ct) => Run(() => s.DiagnoseAsync(r, ct), ct));
|
||||
group.MapPost("/completions", (CompletionsRequest r, ScriptAnalysisService s, CancellationToken ct) => Run(() => s.CompleteAsync(r, ct), ct));
|
||||
group.MapPost("/hover", (HoverRequest r, ScriptAnalysisService s, CancellationToken ct) => Run(() => s.Hover(r, ct), ct));
|
||||
group.MapPost("/signature-help", (SignatureHelpRequest r, ScriptAnalysisService s, CancellationToken ct) => Run(() => Task.FromResult(s.SignatureHelp(r, ct)), ct));
|
||||
group.MapPost("/format", (FormatRequest r, ScriptAnalysisService s) => Results.Ok(s.Format(r)));
|
||||
group.MapPost("/inlay-hints", (InlayHintsRequest r, ScriptAnalysisService s) => Results.Ok(s.InlayHints(r)));
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static async Task<IResult> Run<T>(Func<Task<T>> analyse, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Results.Ok(await analyse());
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
return Results.StatusCode(ClientClosedRequest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,12 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||
/// <see cref="VirtualTagContext"/> context.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// All capabilities are fully implemented: Diagnose, Complete, Hover, SignatureHelp, and Format
|
||||
/// are wired and backed by the real Roslyn document/analysis pipeline. Only InlayHints is
|
||||
/// intentionally a no-op (empty list) — the endpoint exists for future use but the feature is
|
||||
/// not implemented.
|
||||
/// <para>All capabilities are fully implemented: Diagnose, Complete, Hover, SignatureHelp, and Format
|
||||
/// are wired and backed by the real Roslyn document/analysis pipeline.</para>
|
||||
/// <para><b>Cost model.</b> Every analysis derives from one process-wide <see cref="BaseCompilation"/>
|
||||
/// via <see cref="CSharpCompilation.ReplaceSyntaxTree"/>, so the sandbox reference set is bound once rather
|
||||
/// than per keystroke, and every entry point takes the request's <see cref="CancellationToken"/>
|
||||
/// (<c>HttpContext.RequestAborted</c>) so an abandoned request stops instead of running to completion.</para>
|
||||
/// </remarks>
|
||||
public sealed class ScriptAnalysisService
|
||||
{
|
||||
@@ -56,16 +58,39 @@ public sealed class ScriptAnalysisService
|
||||
+ " var ctx = globals.ctx;\n#line 1\n";
|
||||
}
|
||||
|
||||
// userSource is appended after Preamble, then the method/class are closed.
|
||||
private static (SyntaxTree Tree, CSharpCompilation Compilation, SemanticModel Model, int PreambleLength) Analyze(string userSource)
|
||||
private static SyntaxTree WrapAndParse(string userSource, CancellationToken ct)
|
||||
=> CSharpSyntaxTree.ParseText(Preamble + (userSource ?? "") + "\n }\n}\n", cancellationToken: ct);
|
||||
|
||||
/// <summary>
|
||||
/// The process-wide compilation every analysis derives from. Its sandbox reference set is bound ONCE
|
||||
/// (forced here by resolving <c>System.Object</c>); <see cref="CSharpCompilation.ReplaceSyntaxTree"/> keeps
|
||||
/// that binding, so a request only re-binds the user's tree instead of the whole reference set. It is
|
||||
/// immutable, so sharing it across circuits and requests is thread-safe.
|
||||
/// </summary>
|
||||
internal static readonly CSharpCompilation BaseCompilation = CreateBaseCompilation();
|
||||
|
||||
private static CSharpCompilation CreateBaseCompilation()
|
||||
{
|
||||
var prefix = Preamble;
|
||||
var full = prefix + (userSource ?? "") + "\n }\n}\n";
|
||||
var tree = CSharpSyntaxTree.ParseText(full);
|
||||
var compilation = CSharpCompilation.Create("ScriptAnalysis", new[] { tree }, Sandbox.References, CompileOptions);
|
||||
return (tree, compilation, compilation.GetSemanticModel(tree), prefix.Length);
|
||||
var compilation = CSharpCompilation.Create(
|
||||
"ScriptAnalysis", new[] { WrapAndParse("", CancellationToken.None) }, Sandbox.References, CompileOptions);
|
||||
_ = compilation.GetSpecialType(SpecialType.System_Object);
|
||||
return compilation;
|
||||
}
|
||||
|
||||
// userSource is appended after Preamble, then the method/class are closed. Every analysis derives straight
|
||||
// from the shared BaseCompilation: the analysis endpoints are minimal-API handlers, so a scoped service lives
|
||||
// for one HTTP request and a per-instance lineage would never be more than one step long.
|
||||
private static (SyntaxTree Tree, CSharpCompilation Compilation, SemanticModel Model, int PreambleLength) Analyze(
|
||||
string userSource, CancellationToken ct)
|
||||
{
|
||||
var tree = WrapAndParse(userSource, ct);
|
||||
var compilation = BaseCompilation.ReplaceSyntaxTree(BaseCompilation.SyntaxTrees[0], tree);
|
||||
return (tree, compilation, compilation.GetSemanticModel(tree), Preamble.Length);
|
||||
}
|
||||
|
||||
/// <summary>Test seam: the compilation one analysis of <paramref name="userSource"/> runs against.</summary>
|
||||
internal CSharpCompilation CompilationFor(string userSource) => Analyze(Normalize(userSource), CancellationToken.None).Compilation;
|
||||
|
||||
// editor (line,col) in USER-source coords -> physical offset in the wrapped document
|
||||
private static int OffsetInWrapped(string userSource, int line, int column, int preambleLength)
|
||||
=> preambleLength + Math.Clamp(PositionToOffset(userSource ?? "", line, column), 0, (userSource ?? "").Length);
|
||||
@@ -87,14 +112,16 @@ public sealed class ScriptAnalysisService
|
||||
/// <summary>Compiles the script and returns compile-error diagnostics plus sandbox-policy violations
|
||||
/// (forbidden types, disallowed dynamic tag paths).</summary>
|
||||
/// <param name="req">The diagnose request carrying the script source.</param>
|
||||
/// <param name="ct">The request's cancellation token; a cancelled request throws <see cref="OperationCanceledException"/>.</param>
|
||||
/// <returns>The list of diagnostic markers, empty when the script is clean or analysis fails.</returns>
|
||||
public DiagnoseResponse Diagnose(DiagnoseRequest req)
|
||||
public DiagnoseResponse Diagnose(DiagnoseRequest req, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(req.Code)) return new DiagnoseResponse(Array.Empty<DiagnosticMarker>());
|
||||
try
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var code = Normalize(req.Code);
|
||||
var (tree, compilation, _, preambleLength) = Analyze(code);
|
||||
var (tree, compilation, _, preambleLength) = Analyze(code, ct);
|
||||
var markers = new List<DiagnosticMarker>();
|
||||
|
||||
// (1) Roslyn — surface ONLY compile errors, the same severity that actually blocks a
|
||||
@@ -107,7 +134,7 @@ public sealed class ScriptAnalysisService
|
||||
// makes GetMappedLineSpan report user-source coords (GetLineSpan would return the
|
||||
// physical wrapper line and be wrong); the preambleLength guard also drops phantom
|
||||
// wrapper diagnostics like CS0161 on Run() when the user hasn't typed `return` yet.
|
||||
foreach (var d in compilation.GetDiagnostics())
|
||||
foreach (var d in compilation.GetDiagnostics(ct))
|
||||
{
|
||||
if (d.Severity != DiagnosticSeverity.Error || !d.Location.IsInSource) continue;
|
||||
if (d.Location.SourceSpan.Start < preambleLength) continue;
|
||||
@@ -115,13 +142,23 @@ public sealed class ScriptAnalysisService
|
||||
markers.Add(ToMarker(span, Sev(d.Severity), d.GetMessage(), d.Id));
|
||||
}
|
||||
// (2) ForbiddenTypeAnalyzer — spans are in the wrapped tree; map via #line.
|
||||
ct.ThrowIfCancellationRequested();
|
||||
foreach (var r in ForbiddenTypeAnalyzer.Analyze(compilation))
|
||||
markers.Add(ToMarker(tree.GetMappedLineSpan(r.Span), 8, r.Message, "OTSCRIPT_FORBIDDEN"));
|
||||
ct.ThrowIfCancellationRequested();
|
||||
// (3) DependencyExtractor — spans are offsets in the normalized user source; map directly.
|
||||
foreach (var r in DependencyExtractor.Extract(code).Rejections)
|
||||
markers.Add(ToUserMarker(code, r.Span, r.Message, "OTSCRIPT_DYNPATH"));
|
||||
// (4) ctx.SetVirtualTag(...) is inert at runtime — a WARNING (severity 4), never an error: publish
|
||||
// accepts the script, so the editor must too; the marker just puts the hover's caveat in the Problems list.
|
||||
foreach (var inv in SetVirtualTagCalls(tree.GetRoot(ct), preambleLength))
|
||||
markers.Add(ToMarker(tree.GetMappedLineSpan(inv.Span), 4, SetVirtualTagInertMessage, "OTSCRIPT_SETVTAG_INERT"));
|
||||
|
||||
return new DiagnoseResponse(markers.Distinct().ToList());
|
||||
return new DiagnoseResponse(Bound(markers));
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -144,10 +181,11 @@ public sealed class ScriptAnalysisService
|
||||
/// (<c>DraftValidator.ValidateEquipReferenceResolution</c>) rejects, so the editor accepts ⇔ publish
|
||||
/// accepts. Inert (base markers only) on the shared ScriptEdit page where <c>EquipmentId</c> is null.</summary>
|
||||
/// <param name="req">The diagnose request (its <c>EquipmentId</c> carries the owning-equipment context).</param>
|
||||
/// <param name="ct">The request's cancellation token; a cancelled request throws <see cref="OperationCanceledException"/>.</param>
|
||||
/// <returns>The base diagnostics plus any unresolved-reference markers.</returns>
|
||||
public async Task<DiagnoseResponse> DiagnoseAsync(DiagnoseRequest req)
|
||||
public async Task<DiagnoseResponse> DiagnoseAsync(DiagnoseRequest req, CancellationToken ct = default)
|
||||
{
|
||||
var baseResp = Diagnose(req);
|
||||
var baseResp = Diagnose(req, ct);
|
||||
if (_catalog is null || string.IsNullOrEmpty(req.EquipmentId) || string.IsNullOrEmpty(req.Code))
|
||||
return baseResp;
|
||||
try
|
||||
@@ -156,7 +194,7 @@ public sealed class ScriptAnalysisService
|
||||
if (!code.Contains(EquipmentScriptPaths.EquipTokenPrefix, StringComparison.Ordinal))
|
||||
return baseResp;
|
||||
|
||||
var names = await _catalog.GetEquipmentReferenceNamesAsync(req.EquipmentId, null, CancellationToken.None);
|
||||
var names = await _catalog.GetEquipmentReferenceNamesAsync(req.EquipmentId, null, ct);
|
||||
var resolvable = new HashSet<string>(names, StringComparer.Ordinal);
|
||||
var markers = new List<DiagnosticMarker>(baseResp.Markers);
|
||||
|
||||
@@ -173,7 +211,11 @@ public sealed class ScriptAnalysisService
|
||||
$"reference named '{refName}'. Add a matching reference or correct the path.",
|
||||
"OTSCRIPT_EQUIPREF"));
|
||||
}
|
||||
return new DiagnoseResponse(markers.Distinct().ToList());
|
||||
return new DiagnoseResponse(Bound(markers));
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -182,6 +224,35 @@ public sealed class ScriptAnalysisService
|
||||
}
|
||||
}
|
||||
|
||||
// Bounds what the diagnostics endpoint returns (and so what Monaco renders and the Problems list is fed from):
|
||||
// de-duplicated, errors first then by position, at most MaxDiagnosticMarkers, each message cut to
|
||||
// MaxDiagnosticMessageChars. Ordering before the cut keeps an error whenever one exists, so the editor still
|
||||
// rejects exactly what publish rejects even for a pathological (e.g. non-C#) paste.
|
||||
private static IReadOnlyList<DiagnosticMarker> Bound(IEnumerable<DiagnosticMarker> markers)
|
||||
=> ScriptProblems.Order(markers.Distinct())
|
||||
.Take(ScriptEditorLimits.MaxDiagnosticMarkers)
|
||||
.Select(m => m.Message.Length <= ScriptEditorLimits.MaxDiagnosticMessageChars
|
||||
? m
|
||||
: m with { Message = ScriptProblems.Truncate(m.Message, ScriptEditorLimits.MaxDiagnosticMessageChars) })
|
||||
.ToList();
|
||||
|
||||
private const string SetVirtualTagInertMessage =
|
||||
"ctx.SetVirtualTag(...) has no effect at runtime: cross-tag writes are dropped in single-tag mode (the " +
|
||||
"cascade engine is not wired into the host). The script compiles and publishes, but this write will not " +
|
||||
"take effect.";
|
||||
|
||||
// Every ctx.SetVirtualTag(...) invocation in the user source — the same `ctx`-anchored receiver guard as the
|
||||
// tag-path completion (TryGetTagPathLiteral), so comments, string literals and other receivers are ignored.
|
||||
private static IEnumerable<InvocationExpressionSyntax> SetVirtualTagCalls(SyntaxNode root, int preambleLength)
|
||||
=> root.DescendantNodes()
|
||||
.OfType<InvocationExpressionSyntax>()
|
||||
.Where(inv => inv.SpanStart >= preambleLength
|
||||
&& inv.Expression is MemberAccessExpressionSyntax
|
||||
{
|
||||
Expression: IdentifierNameSyntax { Identifier.ValueText: "ctx" },
|
||||
Name.Identifier.ValueText: nameof(ScriptContext.SetVirtualTag),
|
||||
});
|
||||
|
||||
private static int Sev(DiagnosticSeverity s) => s switch
|
||||
{
|
||||
DiagnosticSeverity.Error => 8,
|
||||
@@ -210,16 +281,18 @@ public sealed class ScriptAnalysisService
|
||||
}
|
||||
/// <summary>Computes completion items (tag-path, dot-member, or in-scope symbol completions) at the caret position.</summary>
|
||||
/// <param name="req">The completions request carrying the script source and caret position.</param>
|
||||
/// <param name="ct">The request's cancellation token; a cancelled request throws <see cref="OperationCanceledException"/>.</param>
|
||||
/// <returns>The resolved list of completion items, empty when none apply.</returns>
|
||||
public async Task<CompletionsResponse> CompleteAsync(CompletionsRequest req)
|
||||
public async Task<CompletionsResponse> CompleteAsync(CompletionsRequest req, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(req.CodeText)) return new CompletionsResponse(Array.Empty<CompletionItem>());
|
||||
try
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var code = Normalize(req.CodeText);
|
||||
var (tree, _, model, preambleLength) = Analyze(code);
|
||||
var (tree, _, model, preambleLength) = Analyze(code, ct);
|
||||
var position = OffsetInWrapped(code, req.Line, req.Column, preambleLength);
|
||||
var root = await tree.GetRootAsync();
|
||||
var root = await tree.GetRootAsync(ct);
|
||||
// position is the offset just AFTER the caret; -1 finds the token the caret sits at/just-after (e.g. the dot in "ctx.").
|
||||
var token = root.FindToken(Math.Max(0, position - 1));
|
||||
|
||||
@@ -233,26 +306,31 @@ public sealed class ScriptAnalysisService
|
||||
if (string.IsNullOrEmpty(req.EquipmentId))
|
||||
return new CompletionsResponse(Array.Empty<CompletionItem>());
|
||||
var names = await _catalog.GetEquipmentReferenceNamesAsync(
|
||||
req.EquipmentId, pathPrefix.Substring(equipPrefix.Length), CancellationToken.None);
|
||||
req.EquipmentId, pathPrefix.Substring(equipPrefix.Length), ct);
|
||||
return new CompletionsResponse(names
|
||||
.Select(n => new CompletionItem(equipPrefix + n, equipPrefix + n, "tag path", "Field"))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
var paths = await _catalog.GetPathsAsync(pathPrefix, CancellationToken.None);
|
||||
var paths = await _catalog.GetPathsAsync(pathPrefix, ct);
|
||||
return new CompletionsResponse(
|
||||
paths.Select(p => new CompletionItem(p, p, "tag path", "Field")).ToList());
|
||||
}
|
||||
|
||||
var dot = TryGetDotMembers(token, model);
|
||||
var dot = TryGetDotMembers(token, model, ct);
|
||||
if (dot != null) return new CompletionsResponse(dot);
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var scoped = model.LookupSymbols(position)
|
||||
.Where(s => !s.IsImplicitlyDeclared && !string.IsNullOrEmpty(s.Name))
|
||||
.GroupBy(s => s.Name).Select(g => g.First())
|
||||
.Select(ToCompletionItem).Take(200).ToList();
|
||||
return new CompletionsResponse(scoped);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Script completion failed; returning none.");
|
||||
@@ -260,12 +338,12 @@ public sealed class ScriptAnalysisService
|
||||
}
|
||||
}
|
||||
|
||||
private static List<CompletionItem>? TryGetDotMembers(SyntaxToken token, SemanticModel model)
|
||||
private static List<CompletionItem>? TryGetDotMembers(SyntaxToken token, SemanticModel model, CancellationToken ct)
|
||||
{
|
||||
var memberAccess = token.Parent as MemberAccessExpressionSyntax
|
||||
?? token.GetPreviousToken().Parent as MemberAccessExpressionSyntax;
|
||||
if (memberAccess == null) return null;
|
||||
var typeInfo = model.GetTypeInfo(memberAccess.Expression);
|
||||
var typeInfo = model.GetTypeInfo(memberAccess.Expression, ct);
|
||||
var type = typeInfo.Type ?? typeInfo.ConvertedType;
|
||||
if (type == null) return null;
|
||||
// The type's own members, then the INSTANCE members it inherits (stopping before System.Object, whose
|
||||
@@ -322,16 +400,18 @@ public sealed class ScriptAnalysisService
|
||||
}
|
||||
/// <summary>Computes hover information (tag-path info or C# symbol info) for the position under the caret.</summary>
|
||||
/// <param name="req">The hover request carrying the script source and caret position.</param>
|
||||
/// <param name="ct">The request's cancellation token; a cancelled request throws <see cref="OperationCanceledException"/>.</param>
|
||||
/// <returns>The resolved hover markdown, or a response with a null value when nothing resolves.</returns>
|
||||
public async Task<HoverResponse> Hover(HoverRequest req)
|
||||
public async Task<HoverResponse> Hover(HoverRequest req, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.CodeText)) return new HoverResponse(null);
|
||||
try
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var code = Normalize(req.CodeText);
|
||||
var (tree, _, model, preambleLength) = Analyze(code);
|
||||
var (tree, _, model, preambleLength) = Analyze(code, ct);
|
||||
var position = OffsetInWrapped(code, req.Line, req.Column, preambleLength);
|
||||
var token = tree.GetRoot().FindToken(Math.Max(0, position - 1));
|
||||
var token = tree.GetRoot(ct).FindToken(Math.Max(0, position - 1));
|
||||
|
||||
// Tag-path hover takes priority over C# symbol resolution: when the caret sits on a
|
||||
// ctx.GetTag("…")/ctx.SetVirtualTag("…") path literal, show the resolved tag's info
|
||||
@@ -350,7 +430,7 @@ public sealed class ScriptAnalysisService
|
||||
"(by effective name) to the backing raw tag's path when the VirtualTag is deployed.");
|
||||
}
|
||||
|
||||
var info = await _catalog.GetTagInfoAsync(tagPath, CancellationToken.None);
|
||||
var info = await _catalog.GetTagInfoAsync(tagPath, ct);
|
||||
var tagMd = info is null
|
||||
? $"**Tag path** `{Code(tagPath)}`\n\n⚠ Not a known configured tag path."
|
||||
: $"**Tag path** `{Code(info.Path)}`\n\n{info.Kind} · Type **{info.DataType}**"
|
||||
@@ -368,21 +448,22 @@ public sealed class ScriptAnalysisService
|
||||
// Resolve the token's node; if that yields nothing, climb to the enclosing
|
||||
// member-access (e.g. hovering the `GetTag` identifier — the IdentifierName itself
|
||||
// carries no symbol info, but its parent member-access binds to the method).
|
||||
var symbol = ResolveSymbol(model, node);
|
||||
var symbol = ResolveSymbol(model, node, ct);
|
||||
if (symbol is null) return new HoverResponse(null);
|
||||
var display = symbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat);
|
||||
var summary = TryGetXmlSummary(symbol);
|
||||
var md = "```csharp\n" + display + "\n```" + (summary is null ? "" : "\n\n" + summary);
|
||||
return new HoverResponse(md);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||
catch (Exception ex) { _logger?.LogWarning(ex, "Script hover failed."); return new HoverResponse(null); }
|
||||
}
|
||||
|
||||
private static ISymbol? ResolveSymbol(SemanticModel model, SyntaxNode node)
|
||||
private static ISymbol? ResolveSymbol(SemanticModel model, SyntaxNode node, CancellationToken ct)
|
||||
{
|
||||
foreach (var candidate in ResolutionCandidates(node))
|
||||
{
|
||||
var info = model.GetSymbolInfo(candidate);
|
||||
var info = model.GetSymbolInfo(candidate, ct);
|
||||
var symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault();
|
||||
if (symbol is not null) return symbol;
|
||||
}
|
||||
@@ -420,24 +501,26 @@ public sealed class ScriptAnalysisService
|
||||
|
||||
/// <summary>Computes signature help for the method invocation at the caret position.</summary>
|
||||
/// <param name="req">The signature-help request carrying the script source and caret position.</param>
|
||||
/// <param name="ct">The request's cancellation token; a cancelled request throws <see cref="OperationCanceledException"/>.</param>
|
||||
/// <returns>The resolved signature and active-parameter index, or an empty response when none applies.</returns>
|
||||
public SignatureHelpResponse SignatureHelp(SignatureHelpRequest req)
|
||||
public SignatureHelpResponse SignatureHelp(SignatureHelpRequest req, CancellationToken ct = default)
|
||||
{
|
||||
var empty = new SignatureHelpResponse(null, null, 0);
|
||||
if (string.IsNullOrWhiteSpace(req.CodeText)) return empty;
|
||||
try
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var code = Normalize(req.CodeText);
|
||||
var (tree, _, model, preambleLength) = Analyze(code);
|
||||
var (tree, _, model, preambleLength) = Analyze(code, ct);
|
||||
var position = OffsetInWrapped(code, req.Line, req.Column, preambleLength);
|
||||
var token = tree.GetRoot().FindToken(Math.Max(0, position - 1));
|
||||
var token = tree.GetRoot(ct).FindToken(Math.Max(0, position - 1));
|
||||
|
||||
InvocationExpressionSyntax? inv = null;
|
||||
for (var n = token.Parent; n is not null; n = n.Parent)
|
||||
if (n is InvocationExpressionSyntax found) { inv = found; break; }
|
||||
if (inv is null) return empty;
|
||||
|
||||
var info = model.GetSymbolInfo(inv);
|
||||
var info = model.GetSymbolInfo(inv, ct);
|
||||
var method = (info.Symbol ?? info.CandidateSymbols.FirstOrDefault()) as IMethodSymbol;
|
||||
if (method is null) return empty;
|
||||
|
||||
@@ -453,6 +536,7 @@ public sealed class ScriptAnalysisService
|
||||
|
||||
return new SignatureHelpResponse(label, ps, active);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||
catch (Exception ex) { _logger?.LogWarning(ex, "Script signature help failed."); return empty; }
|
||||
}
|
||||
/// <summary>Formats a script's source code using Roslyn's syntax-tree whitespace normalization.</summary>
|
||||
@@ -474,9 +558,4 @@ public sealed class ScriptAnalysisService
|
||||
return new FormatResponse(req.Code);
|
||||
}
|
||||
}
|
||||
/// <summary>Returns editor inlay hints for the script. Currently a no-op (always empty) — the endpoint
|
||||
/// exists for future use but the feature is not implemented.</summary>
|
||||
/// <param name="req">The inlay-hints request (unused).</param>
|
||||
/// <returns>An always-empty inlay-hints response.</returns>
|
||||
public InlayHintsResponse InlayHints(InlayHintsRequest req) => new(Array.Empty<InlayHint>());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||
|
||||
/// <summary>Size limits for moving Monaco editor text across the Blazor Server circuit.</summary>
|
||||
/// <remarks>
|
||||
/// The editor text crosses JS → .NET only on demand (at blur and when a page saves), never per keystroke. Each
|
||||
/// crossing is ONE SignalR message, bounded by the Blazor hub's <c>MaximumReceiveMessageSize</c>. The framework
|
||||
/// default is 32 KB, which silently lost any larger script (the oversize invoke failed and Save persisted the
|
||||
/// stale source), so <c>AddAdminUI</c> raises it to <see cref="HubMaximumReceiveMessageSize"/>. The editor JS
|
||||
/// refuses to send a value whose serialized form exceeds <see cref="MaxEditorSyncBytes"/> — an oversize frame
|
||||
/// would tear the circuit down — and the save surfaces that refusal to the author instead.
|
||||
/// </remarks>
|
||||
public static class ScriptEditorLimits
|
||||
{
|
||||
/// <summary>The Blazor hub's receive limit, in bytes (1 MB).</summary>
|
||||
public const long HubMaximumReceiveMessageSize = 1024 * 1024;
|
||||
|
||||
/// <summary>The largest serialized editor value the JS will send, leaving 64 KB of frame headroom.</summary>
|
||||
public const int MaxEditorSyncBytes = (int)HubMaximumReceiveMessageSize - 64 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// The most diagnostic markers the editor reports to .NET (for the Problems list) in one circuit message.
|
||||
/// A non-C# paste produces several Roslyn errors per line; uncapped, a few thousand markers exceed the hub
|
||||
/// limit and tear the circuit down. The JS sends the first N in display order (errors first) plus the total.
|
||||
/// Monaco's own in-editor squiggles are local and stay uncapped (up to <see cref="MaxDiagnosticMarkers"/>).
|
||||
/// </summary>
|
||||
public const int MaxReportedMarkers = 200;
|
||||
|
||||
/// <summary>Each reported marker's message is truncated to this many characters before it crosses the circuit.</summary>
|
||||
public const int MaxReportedMessageChars = 300;
|
||||
|
||||
/// <summary>
|
||||
/// The most markers the diagnostics endpoint returns (errors first). Bounds the analysis response and the
|
||||
/// marker set Monaco renders for pathological input; an error is always kept when one exists, so the
|
||||
/// editor still rejects exactly what publish rejects.
|
||||
/// </summary>
|
||||
public const int MaxDiagnosticMarkers = 1000;
|
||||
|
||||
/// <summary>Each diagnostics-endpoint marker message is truncated to this many characters (a Roslyn message
|
||||
/// can quote an arbitrarily long pasted token).</summary>
|
||||
public const int MaxDiagnosticMessageChars = 1000;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// Presentation rules for the script editor's Problems list — the markers Monaco reports through
|
||||
/// <c>MonacoEditor.MarkersChanged</c> (the same <see cref="DiagnosticMarker"/> shape the diagnostics endpoint
|
||||
/// returns), rendered by <c>ScriptProblemsList</c>.
|
||||
/// </summary>
|
||||
public static class ScriptProblems
|
||||
{
|
||||
/// <summary>Monaco's error severity.</summary>
|
||||
public const int Error = 8;
|
||||
|
||||
/// <summary>Monaco's warning severity.</summary>
|
||||
public const int Warning = 4;
|
||||
|
||||
/// <summary>Errors first, then warnings, then the rest; within a severity, by position.</summary>
|
||||
/// <param name="markers">The reported markers (null treated as none).</param>
|
||||
/// <returns>The markers in display order.</returns>
|
||||
public static IReadOnlyList<DiagnosticMarker> Order(IEnumerable<DiagnosticMarker>? markers)
|
||||
=> (markers ?? Array.Empty<DiagnosticMarker>())
|
||||
.OrderByDescending(m => m.Severity)
|
||||
.ThenBy(m => m.StartLineNumber)
|
||||
.ThenBy(m => m.StartColumn)
|
||||
.ToList();
|
||||
|
||||
/// <summary>The label for a Monaco severity value.</summary>
|
||||
/// <param name="severity">8 = error, 4 = warning, 2 = info, otherwise hint.</param>
|
||||
/// <returns>The display label.</returns>
|
||||
public static string SeverityLabel(int severity) => severity switch
|
||||
{
|
||||
Error => "Error",
|
||||
Warning => "Warning",
|
||||
2 => "Info",
|
||||
_ => "Hint",
|
||||
};
|
||||
|
||||
/// <summary>A one-line count, e.g. <c>"2 errors, 1 warning"</c>.</summary>
|
||||
/// <param name="markers">The reported markers.</param>
|
||||
/// <returns>The summary, or <c>"No problems"</c>.</returns>
|
||||
public static string Summary(IReadOnlyCollection<DiagnosticMarker> markers)
|
||||
{
|
||||
if (markers.Count == 0) return "No problems";
|
||||
var errors = markers.Count(m => m.Severity == Error);
|
||||
var warnings = markers.Count(m => m.Severity == Warning);
|
||||
var other = markers.Count - errors - warnings;
|
||||
var parts = new List<string>(3);
|
||||
if (errors > 0) parts.Add(Plural(errors, "error"));
|
||||
if (warnings > 0) parts.Add(Plural(warnings, "warning"));
|
||||
if (other > 0) parts.Add($"{other} other");
|
||||
return string.Join(", ", parts);
|
||||
}
|
||||
|
||||
/// <summary>A one-line count for a (possibly capped) report, e.g. <c>"200 errors (showing 200 of 4321)"</c>.</summary>
|
||||
/// <param name="report">The report the editor sent.</param>
|
||||
/// <returns>The summary of the shown markers, with the shown/total suffix when some were left out.</returns>
|
||||
public static string Summary(ScriptProblemReport report)
|
||||
{
|
||||
var summary = Summary(report.Markers.ToList());
|
||||
return report.Omitted > 0 ? $"{summary} (showing {report.Markers.Count} of {report.Total})" : summary;
|
||||
}
|
||||
|
||||
/// <summary>Cuts <paramref name="text"/> to at most <paramref name="maxChars"/> characters, ending in an ellipsis.</summary>
|
||||
/// <param name="text">The text.</param>
|
||||
/// <param name="maxChars">The maximum length, ellipsis included (at least 1).</param>
|
||||
/// <returns>The text, unchanged when it already fits.</returns>
|
||||
public static string Truncate(string text, int maxChars)
|
||||
=> text.Length <= maxChars ? text : string.Concat(text.AsSpan(0, Math.Max(0, maxChars - 1)), "…");
|
||||
|
||||
private static string Plural(int n, string noun) => n == 1 ? $"1 {noun}" : $"{n} {noun}s";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the editor reported for the Problems list: at most <see cref="ScriptEditorLimits.MaxReportedMarkers"/>
|
||||
/// markers in display order (errors first) plus the <see cref="Total"/> the diagnostics pass produced. The JS caps
|
||||
/// the report before it crosses the circuit — one hub message, bounded by the 1 MB receive limit — and
|
||||
/// <see cref="FromEditor"/> clamps it again on arrival.
|
||||
/// </summary>
|
||||
/// <param name="Markers">The markers shown.</param>
|
||||
/// <param name="Total">How many markers the diagnostics pass produced (≥ <c>Markers.Count</c>).</param>
|
||||
public sealed record ScriptProblemReport(IReadOnlyList<DiagnosticMarker> Markers, int Total)
|
||||
{
|
||||
/// <summary>No problems.</summary>
|
||||
public static readonly ScriptProblemReport Empty = new(Array.Empty<DiagnosticMarker>(), 0);
|
||||
|
||||
/// <summary>How many markers were left out of <see cref="Markers"/>.</summary>
|
||||
public int Omitted => Math.Max(0, Total - Markers.Count);
|
||||
|
||||
/// <summary>Builds a report from what the editor JS sent, re-applying the circuit caps defensively.</summary>
|
||||
/// <param name="markers">The markers sent (already capped by the JS).</param>
|
||||
/// <param name="total">The total the JS counted.</param>
|
||||
/// <returns>The clamped report.</returns>
|
||||
public static ScriptProblemReport FromEditor(IReadOnlyList<DiagnosticMarker>? markers, int total)
|
||||
{
|
||||
if (markers is null || markers.Count == 0) return total <= 0 ? Empty : new(Array.Empty<DiagnosticMarker>(), total);
|
||||
var shown = ScriptProblems.Order(markers)
|
||||
.Take(ScriptEditorLimits.MaxReportedMarkers)
|
||||
.Select(m => m.Message.Length <= ScriptEditorLimits.MaxReportedMessageChars
|
||||
? m
|
||||
: m with { Message = ScriptProblems.Truncate(m.Message, ScriptEditorLimits.MaxReportedMessageChars) })
|
||||
.ToList();
|
||||
return new ScriptProblemReport(shown, Math.Max(total, markers.Count));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// The query-ready form of one <see cref="ScriptTagCatalogSnapshotCache"/> snapshot: the raw entries, a sorted
|
||||
/// de-duplicated path array for prefix completion, and an Ordinal path→info dictionary for hover.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Built ONCE per snapshot (at most every 5 s) instead of per keystroke. Completion used to build a
|
||||
/// <see cref="HashSet{T}"/> of every fleet path and sort it on each request, and hover scanned the entries
|
||||
/// linearly; on a 50k-tag fleet that was tens of MB of garbage per typed character. The semantics are
|
||||
/// unchanged: completion is case-insensitive <c>StartsWith</c>, sorted <see cref="StringComparer.OrdinalIgnoreCase"/>,
|
||||
/// with case-only duplicates collapsed to the first-seen casing; hover is an exact Ordinal match (the runtime
|
||||
/// dependency mux is Ordinal) returning the first entry for a path.
|
||||
/// </remarks>
|
||||
public sealed class ScriptTagCatalogIndex
|
||||
{
|
||||
private static readonly StringComparer SortComparer = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
private readonly Dictionary<string, ScriptTagInfo> _byPath;
|
||||
private readonly string[] _sortedPaths;
|
||||
|
||||
private ScriptTagCatalogIndex(IReadOnlyList<ScriptTagInfo> entries, string[] sortedPaths, Dictionary<string, ScriptTagInfo> byPath)
|
||||
{
|
||||
Entries = entries;
|
||||
_sortedPaths = sortedPaths;
|
||||
_byPath = byPath;
|
||||
}
|
||||
|
||||
/// <summary>The projection the index was built from.</summary>
|
||||
public IReadOnlyList<ScriptTagInfo> Entries { get; }
|
||||
|
||||
/// <summary>Distinct (case-insensitively) paths, sorted <see cref="StringComparer.OrdinalIgnoreCase"/>.</summary>
|
||||
public IReadOnlyList<string> SortedPaths => _sortedPaths;
|
||||
|
||||
/// <summary>Builds the index over a catalog projection.</summary>
|
||||
/// <param name="entries">The catalog entries, in projection order (first occurrence wins on duplicates).</param>
|
||||
/// <returns>The built index.</returns>
|
||||
public static ScriptTagCatalogIndex Build(IReadOnlyList<ScriptTagInfo> entries)
|
||||
{
|
||||
// A stable sort keeps projection order among case-only duplicates, so keeping the first of each run
|
||||
// reproduces the first-added casing a HashSet(OrdinalIgnoreCase) kept.
|
||||
var sorted = new List<string>(entries.Count);
|
||||
foreach (var path in entries.Select(e => e.Path).OrderBy(p => p, SortComparer))
|
||||
{
|
||||
if (sorted.Count > 0 && SortComparer.Equals(sorted[^1], path)) continue;
|
||||
sorted.Add(path);
|
||||
}
|
||||
|
||||
var byPath = new Dictionary<string, ScriptTagInfo>(entries.Count, StringComparer.Ordinal);
|
||||
foreach (var e in entries) byPath.TryAdd(e.Path, e);
|
||||
|
||||
return new ScriptTagCatalogIndex(entries, sorted.ToArray(), byPath);
|
||||
}
|
||||
|
||||
/// <summary>Paths starting (case-insensitively) with <paramref name="prefix"/>, in sorted order.</summary>
|
||||
/// <param name="prefix">The literal prefix; null/whitespace returns the first <paramref name="max"/> paths.</param>
|
||||
/// <param name="max">Upper bound on the returned count.</param>
|
||||
/// <returns>At most <paramref name="max"/> matching paths.</returns>
|
||||
public IReadOnlyList<string> FindByPrefix(string? prefix, int max)
|
||||
{
|
||||
var paths = _sortedPaths;
|
||||
if (string.IsNullOrWhiteSpace(prefix)) return paths.Take(max).ToArray();
|
||||
|
||||
// Under an ordinal-style (char-by-char) comparer every string with prefix P sorts at or after P and the
|
||||
// matches form one contiguous run, so a lower-bound binary search plus a forward scan is exact.
|
||||
var start = Array.BinarySearch(paths, prefix, SortComparer);
|
||||
if (start < 0) start = ~start;
|
||||
|
||||
var result = new List<string>(Math.Min(max, 32));
|
||||
for (var i = start; i < paths.Length && result.Count < max; i++)
|
||||
{
|
||||
if (!paths[i].StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) break;
|
||||
result.Add(paths[i]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Exact (Ordinal) lookup of a path.</summary>
|
||||
/// <param name="path">The path to look up.</param>
|
||||
/// <returns>The first entry for that path, or <see langword="null"/>.</returns>
|
||||
public ScriptTagInfo? TryGet(string path) => _byPath.GetValueOrDefault(path);
|
||||
}
|
||||
+19
-6
@@ -55,24 +55,37 @@ public sealed class ScriptTagCatalogSnapshotCache(TimeProvider timeProvider)
|
||||
public async Task<IReadOnlyList<ScriptTagInfo>> GetOrBuildAsync(
|
||||
Func<CancellationToken, Task<IReadOnlyList<ScriptTagInfo>>> build,
|
||||
CancellationToken ct)
|
||||
=> (await GetOrBuildIndexAsync(build, ct)).Entries;
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="GetOrBuildAsync"/>, but returns the snapshot's query-ready
|
||||
/// <see cref="ScriptTagCatalogIndex"/> (sorted paths + path dictionary), which is built once per rebuild
|
||||
/// rather than once per completion keystroke.
|
||||
/// </summary>
|
||||
/// <param name="build">Builds a fresh projection; invoked at most once per TTL window.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The index over a fresh snapshot, or over one just built.</returns>
|
||||
public async Task<ScriptTagCatalogIndex> GetOrBuildIndexAsync(
|
||||
Func<CancellationToken, Task<IReadOnlyList<ScriptTagInfo>>> build,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var current = _snapshot;
|
||||
if (current is not null && !IsStale(current)) return current.Entries;
|
||||
if (current is not null && !IsStale(current)) return current.Index;
|
||||
|
||||
await _rebuildGate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
// Double-check: while we queued, the caller ahead of us may have rebuilt already.
|
||||
current = _snapshot;
|
||||
if (current is not null && !IsStale(current)) return current.Entries;
|
||||
if (current is not null && !IsStale(current)) return current.Index;
|
||||
|
||||
// A throw here leaves _snapshot untouched (the previous one, if any, keeps serving) and
|
||||
// propagates to this caller only; the finally still releases the gate.
|
||||
var entries = await build(ct);
|
||||
var index = ScriptTagCatalogIndex.Build(await build(ct));
|
||||
|
||||
// Stamped AFTER the build so a slow scan does not hand back an already-expired snapshot.
|
||||
_snapshot = new Snapshot(entries, timeProvider.GetUtcNow());
|
||||
return entries;
|
||||
_snapshot = new Snapshot(index, timeProvider.GetUtcNow());
|
||||
return index;
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -82,5 +95,5 @@ public sealed class ScriptTagCatalogSnapshotCache(TimeProvider timeProvider)
|
||||
|
||||
private bool IsStale(Snapshot snapshot) => timeProvider.GetUtcNow() - snapshot.TakenAt >= Ttl;
|
||||
|
||||
private sealed record Snapshot(IReadOnlyList<ScriptTagInfo> Entries, DateTimeOffset TakenAt);
|
||||
private sealed record Snapshot(ScriptTagCatalogIndex Index, DateTimeOffset TakenAt);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,30 @@
|
||||
|
||||
const KIND_MAP = { Method: 0, Field: 3, Property: 9, Event: 10, Class: 5, Module: 8, Variable: 4, Text: 18 };
|
||||
|
||||
// POSTs to a script-analysis endpoint, honouring Monaco's CancellationToken: when Monaco abandons a request
|
||||
// (the caret moved, the user kept typing) the fetch is aborted, which cancels HttpContext.RequestAborted
|
||||
// server-side so the Roslyn analysis stops instead of running to completion. Resolves to null when the
|
||||
// request was cancelled or failed, so a provider never applies a superseded answer.
|
||||
async function postAnalysis(path, body, token) {
|
||||
if (token && token.isCancellationRequested) return null;
|
||||
const ac = new AbortController();
|
||||
const sub = token && token.onCancellationRequested ? token.onCancellationRequested(function () { ac.abort(); }) : null;
|
||||
try {
|
||||
const resp = await fetch("/api/script-analysis/" + path, {
|
||||
method: "POST", credentials: "same-origin", signal: ac.signal,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
return (token && token.isCancellationRequested) ? null : data;
|
||||
} catch (e) {
|
||||
return null;
|
||||
} finally {
|
||||
if (sub && sub.dispose) sub.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// The owning-equipment id is stamped onto each editor's model at createEditor time so the globally
|
||||
// registered csharp providers (which only receive the model) can thread it into the {{equip}}/<RefName>
|
||||
// completion + diagnostics. Null on the shared ScriptEdit page (equip-ref features inert there).
|
||||
@@ -31,15 +55,12 @@
|
||||
function registerCSharpProviders() {
|
||||
monaco.languages.registerCompletionItemProvider("csharp", {
|
||||
triggerCharacters: [".", "(", "\""],
|
||||
provideCompletionItems: async function (model, position) {
|
||||
provideCompletionItems: async function (model, position, context, token) {
|
||||
try {
|
||||
const resp = await fetch("/api/script-analysis/completions", {
|
||||
method: "POST", credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) })
|
||||
});
|
||||
if (!resp.ok) return { suggestions: [] };
|
||||
const data = await resp.json();
|
||||
const data = await postAnalysis("completions",
|
||||
{ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) },
|
||||
token);
|
||||
if (!data) return { suggestions: [] };
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const wordRange = {
|
||||
startLineNumber: position.lineNumber, endLineNumber: position.lineNumber,
|
||||
@@ -72,67 +93,34 @@
|
||||
}
|
||||
});
|
||||
monaco.languages.registerHoverProvider("csharp", {
|
||||
provideHover: async function (model, position) {
|
||||
provideHover: async function (model, position, token) {
|
||||
try {
|
||||
const resp = await fetch("/api/script-analysis/hover", {
|
||||
method: "POST", credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) })
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
if (!data.markdown) return null;
|
||||
const data = await postAnalysis("hover",
|
||||
{ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) },
|
||||
token);
|
||||
if (!data || !data.markdown) return null;
|
||||
return { contents: [{ value: data.markdown }] };
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
});
|
||||
monaco.languages.registerDocumentFormattingEditProvider("csharp", {
|
||||
provideDocumentFormattingEdits: async function (model) {
|
||||
provideDocumentFormattingEdits: async function (model, options, token) {
|
||||
try {
|
||||
const resp = await fetch("/api/script-analysis/format", {
|
||||
method: "POST", credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code: model.getValue() })
|
||||
});
|
||||
if (!resp.ok) return [];
|
||||
const data = await resp.json();
|
||||
if (typeof data.code !== "string" || data.code === model.getValue()) return [];
|
||||
const data = await postAnalysis("format", { code: model.getValue() }, token);
|
||||
if (!data || typeof data.code !== "string" || data.code === model.getValue()) return [];
|
||||
return [{ range: model.getFullModelRange(), text: data.code }];
|
||||
} catch (e) { return []; }
|
||||
}
|
||||
});
|
||||
monaco.languages.registerInlayHintsProvider("csharp", {
|
||||
provideInlayHints: async function (model) {
|
||||
try {
|
||||
const resp = await fetch("/api/script-analysis/inlay-hints", {
|
||||
method: "POST", credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code: model.getValue() })
|
||||
});
|
||||
if (!resp.ok) return { hints: [], dispose: function () {} };
|
||||
const data = await resp.json();
|
||||
return {
|
||||
hints: (data.hints || []).map(function (h) {
|
||||
return { position: { lineNumber: h.line, column: h.column }, label: h.label, kind: 2, paddingRight: true };
|
||||
}),
|
||||
dispose: function () {}
|
||||
};
|
||||
} catch (e) { return { hints: [], dispose: function () {} }; }
|
||||
}
|
||||
});
|
||||
monaco.languages.registerSignatureHelpProvider("csharp", {
|
||||
signatureHelpTriggerCharacters: ["(", ","],
|
||||
signatureHelpRetriggerCharacters: [","],
|
||||
provideSignatureHelp: async function (model, position) {
|
||||
provideSignatureHelp: async function (model, position, token) {
|
||||
try {
|
||||
const resp = await fetch("/api/script-analysis/signature-help", {
|
||||
method: "POST", credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column })
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
if (!data.label) return null;
|
||||
const data = await postAnalysis("signature-help",
|
||||
{ codeText: model.getValue(), line: position.lineNumber, column: position.column },
|
||||
token);
|
||||
if (!data || !data.label) return null;
|
||||
return {
|
||||
value: {
|
||||
signatures: [{
|
||||
@@ -149,17 +137,74 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchDiagnostics(model) {
|
||||
// Resolves to the marker array, or null when the request was aborted (superseded by a newer edit) — the
|
||||
// caller must then leave the markers alone. A server/network failure still resolves to [] (clears markers).
|
||||
async function fetchDiagnostics(model, signal) {
|
||||
try {
|
||||
const resp = await fetch("/api/script-analysis/diagnostics", {
|
||||
method: "POST", credentials: "same-origin",
|
||||
method: "POST", credentials: "same-origin", signal: signal,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code: model.getValue(), equipmentId: equipmentIdOf(model) })
|
||||
});
|
||||
if (!resp.ok) return [];
|
||||
const data = await resp.json();
|
||||
return data.markers || [];
|
||||
} catch (e) { return []; }
|
||||
} catch (e) { return (signal && signal.aborted) ? null : []; }
|
||||
}
|
||||
|
||||
// The editor text crosses the Blazor circuit (JS -> .NET) only on demand — at blur and when the page saves —
|
||||
// never per keystroke. Each crossing is one SignalR message, bounded by the hub's MaximumReceiveMessageSize;
|
||||
// an oversize message would tear the circuit down, so a value whose serialized form would not fit is refused
|
||||
// here instead (getValue throws a descriptive error that .NET surfaces to the author).
|
||||
// Blazor carries an interop value as a JSON string inside the hub frame; measuring it escaped twice is a
|
||||
// conservative upper bound whichever hub protocol (BlazorPack or JSON) the circuit negotiated.
|
||||
function serializedBytes(v) { return new TextEncoder().encode(JSON.stringify(JSON.stringify(v))).length; }
|
||||
function tooLargeMessage(e, v) {
|
||||
if (!e.maxSyncBytes) return null;
|
||||
const bytes = serializedBytes(v);
|
||||
return bytes > e.maxSyncBytes
|
||||
? "The script is too large for the editor to send to the server (" + bytes + " bytes; the limit is " + e.maxSyncBytes + " bytes)."
|
||||
: null;
|
||||
}
|
||||
function syncValue(e) {
|
||||
if (!e.dirty) return;
|
||||
const v = e.editor.getValue();
|
||||
if (tooLargeMessage(e, v)) {
|
||||
// Stays dirty; the explicit save read reports the error. Tell .NET once (a tiny message) so the
|
||||
// author sees a non-blocking notice now rather than only at Save.
|
||||
if (!e.tooLargeNotified) {
|
||||
e.tooLargeNotified = true;
|
||||
e.dotNetRef.invokeMethodAsync("OnSyncRefused", serializedBytes(v)).catch(function () {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.tooLargeNotified) {
|
||||
e.tooLargeNotified = false;
|
||||
e.dotNetRef.invokeMethodAsync("OnSyncRefused", 0).catch(function () {});
|
||||
}
|
||||
e.dirty = false;
|
||||
e.dotNetRef.invokeMethodAsync("OnValueChanged", v).catch(function () { e.dirty = true; });
|
||||
}
|
||||
|
||||
// The Problems-list report is ONE circuit message too. A non-C# paste yields thousands of markers, so only the
|
||||
// first maxReportedMarkers in display order (errors first, then by position — ScriptProblems.Order) cross, each
|
||||
// message cut to maxReportedMessageChars, plus the total so the list can say how many were left out.
|
||||
// Monaco's own squiggles (setModelMarkers) are local and stay uncapped.
|
||||
function severityOrder(a, b) {
|
||||
return (b.severity - a.severity) || (a.startLineNumber - b.startLineNumber) || (a.startColumn - b.startColumn);
|
||||
}
|
||||
function markerReport(e, markers) {
|
||||
const max = e.maxReportedMarkers || 200, maxChars = e.maxReportedMessageChars || 300;
|
||||
const shown = markers.slice().sort(severityOrder).slice(0, max).map(function (m) {
|
||||
const msg = typeof m.message === "string" && m.message.length > maxChars
|
||||
? m.message.substring(0, maxChars - 1) + "\u2026" : m.message;
|
||||
return {
|
||||
severity: m.severity, startLineNumber: m.startLineNumber, startColumn: m.startColumn,
|
||||
endLineNumber: m.endLineNumber, endColumn: m.endColumn, message: msg,
|
||||
code: typeof m.code === "string" ? m.code.substring(0, 64) : m.code
|
||||
};
|
||||
});
|
||||
return { markers: shown, total: markers.length };
|
||||
}
|
||||
|
||||
// A single body-level node for Monaco's overflow widgets (suggest / hover / parameter hints).
|
||||
@@ -200,22 +245,43 @@
|
||||
// Stamp the owning-equipment id on the model so the global csharp providers can thread it into the
|
||||
// {{equip}}/<RefName> completion + diagnostics (null on the shared ScriptEdit page).
|
||||
try { const m0 = editor.getModel(); if (m0) m0.__otEquipmentId = options.equipmentId || null; } catch (x) {}
|
||||
let diagTimer = null;
|
||||
const scheduleDiagnostics = function () {
|
||||
if (diagTimer) clearTimeout(diagTimer);
|
||||
diagTimer = setTimeout(async function () {
|
||||
const model = editor.getModel(); if (!model) return;
|
||||
const markers = await fetchDiagnostics(model);
|
||||
monaco.editor.setModelMarkers(model, "otopcua", markers);
|
||||
dotNetRef.invokeMethodAsync("OnMarkersChanged", markers).catch(function () {});
|
||||
}, 500);
|
||||
const e = {
|
||||
editor: editor, dotNetRef: dotNetRef, dirty: false,
|
||||
maxSyncBytes: options.maxSyncBytes || 0, reportMarkers: !!options.reportMarkers,
|
||||
maxReportedMarkers: options.maxReportedMarkers || 200, maxReportedMessageChars: options.maxReportedMessageChars || 300,
|
||||
tooLargeNotified: false,
|
||||
diagTimer: null, diagGeneration: 0, diagAbort: null
|
||||
};
|
||||
// Diagnostics: the 500 ms debounce delays the START of a request; the generation counter + AbortController
|
||||
// make sure only the response for the CURRENT text is applied. Every edit bumps the generation and aborts
|
||||
// the in-flight request, so a slow older response can never overwrite newer markers.
|
||||
const runDiagnostics = async function () {
|
||||
const model = editor.getModel(); if (!model) return;
|
||||
const generation = ++e.diagGeneration;
|
||||
const ac = new AbortController();
|
||||
e.diagAbort = ac;
|
||||
const markers = await fetchDiagnostics(model, ac.signal);
|
||||
if (markers === null || generation !== e.diagGeneration || editors[id] !== e) return; // superseded
|
||||
e.diagAbort = null;
|
||||
monaco.editor.setModelMarkers(model, "otopcua", markers);
|
||||
if (e.reportMarkers) {
|
||||
const report = markerReport(e, markers);
|
||||
dotNetRef.invokeMethodAsync("OnMarkersChanged", report.markers, report.total).catch(function () {});
|
||||
}
|
||||
};
|
||||
e.scheduleDiagnostics = function () {
|
||||
e.diagGeneration++;
|
||||
if (e.diagAbort) { e.diagAbort.abort(); e.diagAbort = null; }
|
||||
if (e.diagTimer) clearTimeout(e.diagTimer);
|
||||
e.diagTimer = setTimeout(runDiagnostics, 500);
|
||||
};
|
||||
editor.onDidChangeModelContent(function () {
|
||||
dotNetRef.invokeMethodAsync("OnValueChanged", editor.getValue()).catch(function () {});
|
||||
if (options.language === "csharp") scheduleDiagnostics();
|
||||
e.dirty = true;
|
||||
if (options.language === "csharp") e.scheduleDiagnostics();
|
||||
});
|
||||
editors[id] = { editor: editor, dotNetRef: dotNetRef };
|
||||
if (options.language === "csharp") scheduleDiagnostics();
|
||||
editor.onDidBlurEditorText(function () { syncValue(e); });
|
||||
editors[id] = e;
|
||||
if (options.language === "csharp") e.scheduleDiagnostics();
|
||||
}
|
||||
function setEditorOption(id, name, value) {
|
||||
const e = editors[id]; if (!e) return;
|
||||
@@ -228,10 +294,28 @@
|
||||
const e = editors[id]; if (!e) return;
|
||||
e.editor.revealLineInCenter(line); e.editor.setPosition({ lineNumber: line, column: col || 1 }); e.editor.focus();
|
||||
}
|
||||
function setValue(id, v) { const e = editors[id]; if (e && e.editor.getValue() !== v) e.editor.setValue(v || ""); }
|
||||
function getValue(id) { const e = editors[id]; return e ? e.editor.getValue() : null; }
|
||||
function setValue(id, v) {
|
||||
const e = editors[id]; if (!e) return;
|
||||
if (e.editor.getValue() !== v) e.editor.setValue(v || "");
|
||||
e.dirty = false; // the text now equals what .NET holds
|
||||
}
|
||||
// Reads the current text on demand (Save). Throws when it would not fit in one circuit message.
|
||||
function getValue(id) {
|
||||
const e = editors[id]; if (!e) return null;
|
||||
const v = e.editor.getValue();
|
||||
const err = tooLargeMessage(e, v);
|
||||
if (err) throw new Error(err);
|
||||
e.dirty = false;
|
||||
return v;
|
||||
}
|
||||
function setMarkers(id, m) { const e = editors[id]; const model = e && e.editor.getModel(); if (model) monaco.editor.setModelMarkers(model, "otopcua", m || []); }
|
||||
function dispose(id) { const e = editors[id]; if (!e) return; try { e.editor.dispose(); } catch (x) {} delete editors[id]; }
|
||||
function dispose(id) {
|
||||
const e = editors[id]; if (!e) return;
|
||||
if (e.diagTimer) clearTimeout(e.diagTimer);
|
||||
if (e.diagAbort) e.diagAbort.abort();
|
||||
try { e.editor.dispose(); } catch (x) {}
|
||||
delete editors[id];
|
||||
}
|
||||
|
||||
window.MonacoBlazor = { createEditor, setValue, getValue, setMarkers, setEditorOption, format, revealLine, dispose };
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
using CliFx.Exceptions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.Fakes;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>historyread --aggregate</c> accepts every aggregate the client offers (which is exactly the served set), so the
|
||||
/// gate recipes in docs/Historian.md that need Range / Delta / PercentGood / ... can run from this CLI.
|
||||
/// </summary>
|
||||
public class HistoryReadAggregateParsingTests
|
||||
{
|
||||
/// <summary>Every client aggregate, by its name.</summary>
|
||||
/// <returns>Each <see cref="AggregateType"/> value.</returns>
|
||||
public static TheoryData<AggregateType> AllAggregates() => new(Enum.GetValues<AggregateType>());
|
||||
|
||||
/// <summary>Every aggregate name (in any case) reaches the service as that aggregate.</summary>
|
||||
/// <param name="aggregate">The aggregate.</param>
|
||||
[Theory]
|
||||
[MemberData(nameof(AllAggregates))]
|
||||
public async Task Every_aggregate_name_parses(AggregateType aggregate)
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var command = new HistoryReadCommand(new FakeOpcUaClientServiceFactory(fakeService))
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=HistNode",
|
||||
Aggregate = aggregate.ToString().ToUpperInvariant()
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await command.ExecuteAsync(console);
|
||||
|
||||
fakeService.HistoryReadAggregateCalls.ShouldHaveSingleItem().Aggregate.ShouldBe(aggregate);
|
||||
}
|
||||
|
||||
/// <summary>The short aliases still parse.</summary>
|
||||
/// <param name="alias">The alias.</param>
|
||||
/// <param name="expected">The aggregate it names.</param>
|
||||
[Theory]
|
||||
[InlineData("avg", AggregateType.Average)]
|
||||
[InlineData("min", AggregateType.Minimum)]
|
||||
[InlineData("last", AggregateType.End)]
|
||||
[InlineData("interp", AggregateType.Interpolative)]
|
||||
[InlineData("mintime", AggregateType.MinimumActualTime)]
|
||||
[InlineData("maxtime", AggregateType.MaximumActualTime)]
|
||||
public async Task Aliases_parse(string alias, AggregateType expected)
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var command = new HistoryReadCommand(new FakeOpcUaClientServiceFactory(fakeService))
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=HistNode",
|
||||
Aggregate = alias
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
await command.ExecuteAsync(console);
|
||||
|
||||
fakeService.HistoryReadAggregateCalls.ShouldHaveSingleItem().Aggregate.ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// StandardDeviation is no longer offered (no backend serves it): it is refused before connecting, naming the
|
||||
/// supported set.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task StandardDeviation_is_refused_before_connecting()
|
||||
{
|
||||
var fakeService = new FakeOpcUaClientService();
|
||||
var command = new HistoryReadCommand(new FakeOpcUaClientServiceFactory(fakeService))
|
||||
{
|
||||
Url = "opc.tcp://localhost:4840",
|
||||
NodeId = "ns=2;s=HistNode",
|
||||
Aggregate = "stddev"
|
||||
};
|
||||
|
||||
using var console = TestConsoleHelper.CreateConsole();
|
||||
var ex = await Should.ThrowAsync<CommandException>(async () => await command.ExecuteAsync(console));
|
||||
|
||||
ex.Message.ShouldContain("PercentGood");
|
||||
fakeService.HistoryReadAggregateCalls.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Adapters;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// A deleted subscription must also leave the SDK session's <c>Subscriptions</c> list, or every alarm re-target in the UI
|
||||
/// leaves a dead <see cref="Subscription"/> behind until the session is disposed.
|
||||
/// </summary>
|
||||
public class DefaultSubscriptionAdapterTests
|
||||
{
|
||||
/// <summary>DeleteAsync detaches the wrapped subscription exactly once, even when Dispose follows.</summary>
|
||||
[Fact]
|
||||
public async Task DeleteAsync_detaches_the_subscription_from_its_session_once()
|
||||
{
|
||||
var subscription = new Subscription();
|
||||
var detached = new List<Subscription>();
|
||||
var adapter = new DefaultSubscriptionAdapter(subscription, (s, _) =>
|
||||
{
|
||||
lock (detached) detached.Add(s);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
await adapter.DeleteAsync(TestContext.Current.CancellationToken);
|
||||
adapter.Dispose();
|
||||
|
||||
detached.ShouldHaveSingleItem().ShouldBeSameAs(subscription);
|
||||
}
|
||||
|
||||
/// <summary>Dispose alone (no prior DeleteAsync) detaches too.</summary>
|
||||
[Fact]
|
||||
public void Dispose_detaches_the_subscription_from_its_session()
|
||||
{
|
||||
var detached = 0;
|
||||
var adapter = new DefaultSubscriptionAdapter(new Subscription(), (_, _) =>
|
||||
{
|
||||
Interlocked.Increment(ref detached);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
adapter.Dispose();
|
||||
|
||||
detached.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>A failing detach is logged, not thrown: teardown stays best effort.</summary>
|
||||
[Fact]
|
||||
public async Task A_failing_detach_does_not_throw_from_DeleteAsync()
|
||||
{
|
||||
var adapter = new DefaultSubscriptionAdapter(new Subscription(),
|
||||
(_, _) => throw new InvalidOperationException("boom"));
|
||||
|
||||
await Should.NotThrowAsync(() => adapter.DeleteAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Fakes;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Lane K review I1: a keep-alive failover belongs to the connection it started under. A user Disconnect + Connect
|
||||
/// while it is still connecting starts a new connection epoch; the stale failover must abandon itself — never
|
||||
/// overwrite the user's new session, never flip its state. Every step is gate-driven (no sleeps).
|
||||
/// </summary>
|
||||
public sealed class FailoverEpochTests : IDisposable
|
||||
{
|
||||
private const string Primary = "opc.tcp://primary:4840";
|
||||
private const string Backup = "opc.tcp://backup:4840";
|
||||
private static readonly TimeSpan Wait = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly FakeSessionFactory _factory = new();
|
||||
private readonly OpcUaClientService _service;
|
||||
private readonly List<ConnectionState> _states = [];
|
||||
|
||||
public FailoverEpochTests()
|
||||
{
|
||||
_service = new OpcUaClientService(new FakeApplicationConfigurationFactory(), new FakeEndpointDiscovery(), _factory);
|
||||
_service.ConnectionStateChanged += (_, e) => { lock (_states) _states.Add(e.NewState); };
|
||||
}
|
||||
|
||||
public void Dispose() => _service.Dispose();
|
||||
|
||||
private static ConnectionSettings Settings() => new()
|
||||
{
|
||||
EndpointUrl = Primary, FailoverUrls = [Backup], SessionTimeoutSeconds = 60
|
||||
};
|
||||
|
||||
/// <summary>Connects S1, then holds a keep-alive failover inside its connect attempt.</summary>
|
||||
private async Task<(FakeSessionAdapter S1, Task Failover, TaskCompletionSource Gate)> StartHeldFailoverAsync()
|
||||
{
|
||||
var s1 = new FakeSessionAdapter();
|
||||
_factory.EnqueueSession(s1);
|
||||
await _service.ConnectAsync(Settings());
|
||||
|
||||
var gate = new TaskCompletionSource();
|
||||
_factory.CreateGate = gate;
|
||||
s1.SimulateKeepAlive(false);
|
||||
var failover = _service.LastFailover.ShouldNotBeNull();
|
||||
await _factory.CreateEntered.Task.WaitAsync(Wait);
|
||||
return (s1, failover, gate);
|
||||
}
|
||||
|
||||
/// <summary>The stale failover's attempt SUCCEEDS after the user reconnected: its session is disposed, the user's stays.</summary>
|
||||
[Fact]
|
||||
public async Task A_stale_failover_that_connects_does_not_replace_the_users_new_session()
|
||||
{
|
||||
var (_, failover, gate) = await StartHeldFailoverAsync();
|
||||
var userSession = new FakeSessionAdapter();
|
||||
var failoverSession = new FakeSessionAdapter();
|
||||
_factory.EnqueueSession(userSession); // dequeued by the user's Connect (the failover is still held)
|
||||
_factory.EnqueueSession(failoverSession); // dequeued by the failover once released
|
||||
|
||||
await _service.DisconnectAsync();
|
||||
await _service.ConnectAsync(Settings());
|
||||
lock (_states) _states.Clear();
|
||||
|
||||
gate.SetResult();
|
||||
await failover.WaitAsync(Wait);
|
||||
|
||||
failoverSession.Disposed.ShouldBeTrue();
|
||||
userSession.Disposed.ShouldBeFalse();
|
||||
_service.IsConnected.ShouldBeTrue();
|
||||
_service.CurrentConnectionInfo!.EndpointUrl.ShouldBe(Primary);
|
||||
lock (_states) _states.ShouldBeEmpty(); // the stale failover made no state transition at all
|
||||
}
|
||||
|
||||
/// <summary>The stale failover's attempts all FAIL after the user reconnected: the user's connection stays Connected.</summary>
|
||||
[Fact]
|
||||
public async Task A_stale_failover_that_fails_does_not_flip_the_users_connection_to_Disconnected()
|
||||
{
|
||||
var (_, failover, gate) = await StartHeldFailoverAsync();
|
||||
_factory.EnqueueSession(new FakeSessionAdapter());
|
||||
|
||||
await _service.DisconnectAsync();
|
||||
await _service.ConnectAsync(Settings());
|
||||
lock (_states) _states.Clear();
|
||||
var createsBefore = _factory.CreateCallCount;
|
||||
|
||||
_factory.ThrowOnCreate = true;
|
||||
gate.SetResult();
|
||||
await failover.WaitAsync(Wait);
|
||||
|
||||
_service.IsConnected.ShouldBeTrue();
|
||||
lock (_states) _states.ShouldBeEmpty();
|
||||
// It stopped at the abandoned attempt instead of trying every remaining endpoint (review m5).
|
||||
_factory.CreateCallCount.ShouldBe(createsBefore);
|
||||
// And the user can still disconnect / reconnect normally afterwards.
|
||||
_factory.ThrowOnCreate = false;
|
||||
await _service.DisconnectAsync();
|
||||
await _service.ConnectAsync(Settings());
|
||||
_service.IsConnected.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Positive control: an undisturbed failover still reconnects to the backup.</summary>
|
||||
[Fact]
|
||||
public async Task A_genuine_failover_still_reconnects()
|
||||
{
|
||||
var (_, failover, gate) = await StartHeldFailoverAsync();
|
||||
_factory.EnqueueSession(new FakeSessionAdapter());
|
||||
|
||||
gate.SetResult();
|
||||
await failover.WaitAsync(Wait);
|
||||
|
||||
_service.IsConnected.ShouldBeTrue();
|
||||
_service.CurrentConnectionInfo!.EndpointUrl.ShouldBe(Backup);
|
||||
lock (_states) _states.ShouldContain(ConnectionState.Reconnecting);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A late bad keep-alive from the PREVIOUS connection's session must not start a failover against the new one.
|
||||
/// Positive control: the same signal from the current session does (the genuine-failover test above).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_keep_alive_from_a_previous_connection_does_not_fail_over_the_current_one()
|
||||
{
|
||||
var s1 = new FakeSessionAdapter();
|
||||
_factory.EnqueueSession(s1);
|
||||
await _service.ConnectAsync(Settings());
|
||||
await _service.DisconnectAsync();
|
||||
_factory.EnqueueSession(new FakeSessionAdapter());
|
||||
await _service.ConnectAsync(Settings());
|
||||
var creates = _factory.CreateCallCount;
|
||||
lock (_states) _states.Clear();
|
||||
|
||||
s1.SimulateKeepAlive(false);
|
||||
if (_service.LastFailover is { } t) await t.WaitAsync(Wait);
|
||||
|
||||
_factory.CreateCallCount.ShouldBe(creates);
|
||||
lock (_states) _states.ShouldBeEmpty();
|
||||
_service.IsConnected.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Review m8: re-entrant bad keep-alives do not overwrite the seam with an already-completed no-op.</summary>
|
||||
[Fact]
|
||||
public async Task Reentrant_keep_alives_leave_the_seam_on_the_running_failover()
|
||||
{
|
||||
var (s1, failover, gate) = await StartHeldFailoverAsync();
|
||||
s1.SimulateKeepAlive(false);
|
||||
s1.SimulateKeepAlive(false);
|
||||
|
||||
_service.LastFailover.ShouldBeSameAs(failover);
|
||||
failover.IsCompleted.ShouldBeFalse();
|
||||
_factory.EnqueueSession(new FakeSessionAdapter());
|
||||
gate.SetResult();
|
||||
await failover.WaitAsync(Wait);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review m5: an Unsubscribe whose server-side remove fails (the session died under it) still drops the
|
||||
/// bookkeeping, so a failover does not replay an item the caller asked to stop. Proven by a re-subscribe of the
|
||||
/// same node creating a fresh monitored item instead of returning "already subscribed".
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_failed_unsubscribe_still_forgets_the_item()
|
||||
{
|
||||
var sub = new FakeSubscriptionAdapter();
|
||||
_factory.EnqueueSession(new FakeSessionAdapter { NextSubscription = sub });
|
||||
await _service.ConnectAsync(Settings());
|
||||
var node = new Opc.Ua.NodeId("ns=2;s=X");
|
||||
await _service.SubscribeAsync(node);
|
||||
|
||||
sub.ThrowOnRemove = true;
|
||||
await Should.ThrowAsync<Opc.Ua.ServiceResultException>(() => _service.UnsubscribeAsync(node));
|
||||
sub.ThrowOnRemove = false;
|
||||
|
||||
await _service.SubscribeAsync(node);
|
||||
sub.AddDataChangeCount.ShouldBe(2);
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,6 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
|
||||
public int BrowseCount { get; private set; }
|
||||
/// <summary>Gets the number of times BrowseNextAsync has been called.</summary>
|
||||
public int BrowseNextCount { get; private set; }
|
||||
/// <summary>Gets the number of times HasChildrenAsync has been called.</summary>
|
||||
public int HasChildrenCount { get; private set; }
|
||||
/// <summary>Gets the number of times HistoryReadRawAsync has been called.</summary>
|
||||
public int HistoryReadRawCount { get; private set; }
|
||||
/// <summary>Gets the number of times HistoryReadAggregateAsync has been called.</summary>
|
||||
@@ -57,8 +55,6 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
|
||||
public ReferenceDescriptionCollection BrowseNextResponse { get; set; } = [];
|
||||
/// <summary>Gets or sets the continuation point for browse-next operations.</summary>
|
||||
public byte[]? BrowseNextContinuationPoint { get; set; }
|
||||
/// <summary>Gets or sets the result returned by has-children operations.</summary>
|
||||
public bool HasChildrenResponse { get; set; } = false;
|
||||
|
||||
/// <summary>Gets or sets the historical values returned by raw history-read operations.</summary>
|
||||
public List<DataValue> HistoryReadRawResponse { get; set; } = [];
|
||||
@@ -150,13 +146,6 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
|
||||
return Task.FromResult((BrowseNextContinuationPoint, BrowseNextResponse));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> HasChildrenAsync(NodeId nodeId, CancellationToken ct)
|
||||
{
|
||||
HasChildrenCount++;
|
||||
return Task.FromResult(HasChildrenResponse);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(
|
||||
NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues, CancellationToken ct)
|
||||
@@ -178,15 +167,31 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
|
||||
return Task.FromResult<IReadOnlyList<DataValue>>(HistoryReadAggregateResponse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional gate that, when set, holds <see cref="CreateSubscriptionAsync" /> open until completed, so a test can keep
|
||||
/// a subscribe in flight while something else (a failover) happens. Signalled via <see cref="CreateSubscriptionEntered" />.
|
||||
/// </summary>
|
||||
public TaskCompletionSource? CreateSubscriptionGate { get; set; }
|
||||
|
||||
/// <summary>Completed when <see cref="CreateSubscriptionAsync" /> is entered while a gate is set.</summary>
|
||||
public TaskCompletionSource CreateSubscriptionEntered { get; } =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ISubscriptionAdapter> CreateSubscriptionAsync(int publishingIntervalMs, CancellationToken ct)
|
||||
public async Task<ISubscriptionAdapter> CreateSubscriptionAsync(int publishingIntervalMs, CancellationToken ct)
|
||||
{
|
||||
if (CreateSubscriptionGate is { } gate)
|
||||
{
|
||||
CreateSubscriptionEntered.TrySetResult();
|
||||
await gate.Task;
|
||||
}
|
||||
|
||||
lock (_createdSubscriptions)
|
||||
{
|
||||
var sub = NextSubscription ?? new FakeSubscriptionAdapter();
|
||||
NextSubscription = null;
|
||||
_createdSubscriptions.Add(sub);
|
||||
return Task.FromResult<ISubscriptionAdapter>(sub);
|
||||
return sub;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ internal sealed class FakeSessionFactory : ISessionFactory
|
||||
/// </summary>
|
||||
public TaskCompletionSource? CreateGate { get; set; }
|
||||
|
||||
/// <summary>Completed when a call observes <see cref="CreateGate" />.</summary>
|
||||
public TaskCompletionSource CreateEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>Gets the list of sessions created via CreateSessionAsync.</summary>
|
||||
public IReadOnlyList<FakeSessionAdapter> CreatedSessions => _createdSessions;
|
||||
|
||||
@@ -39,8 +42,14 @@ internal sealed class FakeSessionFactory : ISessionFactory
|
||||
CreateCallCount++;
|
||||
LastEndpointUrl = endpoint.EndpointUrl;
|
||||
|
||||
if (CreateGate != null)
|
||||
await CreateGate.Task;
|
||||
// One-shot: the gate holds only the call that observed it, so a later call (e.g. a user's Connect while a
|
||||
// failover is held) runs freely.
|
||||
if (CreateGate is { } gate)
|
||||
{
|
||||
CreateGate = null;
|
||||
CreateEntered.TrySetResult();
|
||||
await gate.Task;
|
||||
}
|
||||
|
||||
if (ThrowOnCreate)
|
||||
throw new InvalidOperationException("FakeSessionFactory configured to fail.");
|
||||
|
||||
@@ -37,6 +37,9 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
|
||||
/// <summary>Gets the number of times AddEventMonitoredItemAsync was called.</summary>
|
||||
public int AddEventCount { get; private set; }
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether RemoveMonitoredItemAsync throws (a session that died).</summary>
|
||||
public bool ThrowOnRemove { get; set; }
|
||||
|
||||
/// <summary>Gets the number of times RemoveMonitoredItemAsync was called.</summary>
|
||||
public int RemoveCount { get; private set; }
|
||||
|
||||
@@ -70,6 +73,8 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
|
||||
/// <inheritdoc />
|
||||
public Task RemoveMonitoredItemAsync(uint clientHandle, CancellationToken ct)
|
||||
{
|
||||
if (ThrowOnRemove)
|
||||
throw new ServiceResultException(StatusCodes.BadSessionClosed, "Session closed");
|
||||
lock (_itemsLock)
|
||||
{
|
||||
RemoveCount++;
|
||||
|
||||
+29
-10
@@ -3,6 +3,7 @@ using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Helpers;
|
||||
|
||||
@@ -17,7 +18,6 @@ public class AggregateTypeMapperTests
|
||||
[InlineData(AggregateType.Count)]
|
||||
[InlineData(AggregateType.Start)]
|
||||
[InlineData(AggregateType.End)]
|
||||
[InlineData(AggregateType.StandardDeviation)]
|
||||
[InlineData(AggregateType.TimeAverage)]
|
||||
[InlineData(AggregateType.TimeAverage2)]
|
||||
[InlineData(AggregateType.Total)]
|
||||
@@ -72,14 +72,6 @@ public class AggregateTypeMapperTests
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.End).ShouldBe(ObjectIds.AggregateFunction_End);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that StandardDeviation aggregate type maps to the correct OPC UA node ID.</summary>
|
||||
[Fact]
|
||||
public void ToNodeId_StandardDeviation_MapsCorrectly()
|
||||
{
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.StandardDeviation)
|
||||
.ShouldBe(ObjectIds.AggregateFunction_StandardDeviationPopulation);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that invalid aggregate type value throws ArgumentOutOfRangeException.</summary>
|
||||
[Fact]
|
||||
public void ToNodeId_InvalidValue_Throws()
|
||||
@@ -98,4 +90,31 @@ public class AggregateTypeMapperTests
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.Total2).ShouldBe(ObjectIds.AggregateFunction_Total2);
|
||||
AggregateTypeMapper.ToNodeId(AggregateType.Interpolative).ShouldBe(ObjectIds.AggregateFunction_Interpolative);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The client offers exactly the aggregates the server's SQL backends serve (<see cref="AggregateFamilies.Served"/>):
|
||||
/// nothing the server always refuses (the old <c>StandardDeviation</c>), nothing served left unreachable
|
||||
/// (Range, Delta, Min/MaxActualTime, PercentGood/Bad).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Client_aggregates_are_exactly_the_served_set()
|
||||
{
|
||||
Enum.GetNames<AggregateType>().Order()
|
||||
.ShouldBe(AggregateFamilies.Served.Select(a => a.ToString()).Order());
|
||||
}
|
||||
|
||||
/// <summary>Each aggregate maps to the Part 13 ObjectId of the same name.</summary>
|
||||
/// <param name="aggregate">The aggregate.</param>
|
||||
[Theory]
|
||||
[MemberData(nameof(AllAggregates))]
|
||||
public void ToNodeId_maps_to_the_same_named_AggregateFunction(AggregateType aggregate)
|
||||
{
|
||||
var field = typeof(ObjectIds).GetField("AggregateFunction_" + aggregate);
|
||||
field.ShouldNotBeNull();
|
||||
AggregateTypeMapper.ToNodeId(aggregate).ShouldBe((NodeId)field.GetValue(null)!);
|
||||
}
|
||||
|
||||
/// <summary>Every client aggregate.</summary>
|
||||
/// <returns>Each <see cref="AggregateType"/> value.</returns>
|
||||
public static TheoryData<AggregateType> AllAggregates() => new(Enum.GetValues<AggregateType>());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the client-side HistoryRead paging loop shared by the raw and modified reads: continuation pages are read
|
||||
/// (never released) while the caller wants more, and a read stopped by the caller's own cap releases the server cursor.
|
||||
/// </summary>
|
||||
public class HistoryReadPagingTests
|
||||
{
|
||||
private static readonly NodeId Node = new("t", 2);
|
||||
|
||||
/// <summary>One recorded call to the page reader.</summary>
|
||||
private sealed record Call(byte[]? ContinuationPoint, bool Release);
|
||||
|
||||
/// <summary>A scripted server: each read returns the next page; a release returns an empty Good result.</summary>
|
||||
private sealed class ScriptedServer
|
||||
{
|
||||
private readonly Queue<(int Count, byte[]? Cp)> _pages;
|
||||
public List<Call> Calls { get; } = [];
|
||||
|
||||
public ScriptedServer(params (int Count, byte[]? Cp)[] pages) => _pages = new Queue<(int, byte[]?)>(pages);
|
||||
|
||||
public Task<HistoryReadResult?> ReadPageAsync(byte[]? cp, bool release, CancellationToken ct)
|
||||
{
|
||||
Calls.Add(new Call(cp, release));
|
||||
if (release) return Task.FromResult<HistoryReadResult?>(new HistoryReadResult { StatusCode = StatusCodes.Good });
|
||||
var (count, next) = _pages.Dequeue();
|
||||
var data = new HistoryData();
|
||||
for (var i = 0; i < count; i++) data.DataValues.Add(new DataValue(new Variant(i)));
|
||||
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
|
||||
{
|
||||
StatusCode = StatusCodes.Good,
|
||||
ContinuationPoint = next,
|
||||
HistoryData = new ExtensionObject(data)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<DataValue> Values(HistoryReadResult r) =>
|
||||
r.HistoryData?.Body is HistoryData d ? d.DataValues : [];
|
||||
|
||||
/// <summary>
|
||||
/// A server page limit below the caller's cap hands back a continuation point: the follow-up must READ the next
|
||||
/// page. Releasing it instead (the old raw path passed <c>release = cp != null</c>) silently truncated every raw read
|
||||
/// to its first server page.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Continuation_pages_are_read_not_released_until_the_server_finishes()
|
||||
{
|
||||
var server = new ScriptedServer((3, [1]), (3, [2]), (2, null));
|
||||
|
||||
var values = await HistoryReadPaging.ReadAllAsync(server.ReadPageAsync, Values, maxValues: 100, Node,
|
||||
CancellationToken.None);
|
||||
|
||||
values.Count.ShouldBe(8);
|
||||
server.Calls.Select(c => c.Release).ShouldAllBe(r => r == false);
|
||||
server.Calls.Select(c => c.ContinuationPoint?.FirstOrDefault()).ShouldBe(new byte?[] { null, 1, 2 });
|
||||
}
|
||||
|
||||
/// <summary>A read stopped by the caller's cap while the server still holds a cursor releases that cursor.</summary>
|
||||
[Fact]
|
||||
public async Task A_read_stopped_by_the_cap_releases_the_outstanding_continuation_point()
|
||||
{
|
||||
var server = new ScriptedServer((5, [7]));
|
||||
|
||||
var values = await HistoryReadPaging.ReadAllAsync(server.ReadPageAsync, Values, maxValues: 5, Node,
|
||||
CancellationToken.None);
|
||||
|
||||
values.Count.ShouldBe(5);
|
||||
server.Calls.Count.ShouldBe(2);
|
||||
server.Calls[1].Release.ShouldBeTrue();
|
||||
server.Calls[1].ContinuationPoint.ShouldBe(new byte[] { 7 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A read the server finished (no continuation point) has nothing to release — and the positive control above
|
||||
/// proves a release would have been recorded had one been owed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_read_the_server_finished_issues_no_release()
|
||||
{
|
||||
var server = new ScriptedServer((5, null));
|
||||
|
||||
var values = await HistoryReadPaging.ReadAllAsync(server.ReadPageAsync, Values, maxValues: 5, Node,
|
||||
CancellationToken.None);
|
||||
|
||||
values.Count.ShouldBe(5);
|
||||
server.Calls.ShouldHaveSingleItem().Release.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review m3: a non-positive cap is refused. An uncapped client read would materialise a tag's whole window
|
||||
/// (the client side of R08 1.1), and <c>(uint)-1</c> on the wire asked the server for everything.
|
||||
/// </summary>
|
||||
/// <param name="max">The cap.</param>
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
public async Task A_non_positive_cap_is_refused_before_any_read(int max)
|
||||
{
|
||||
var server = new ScriptedServer();
|
||||
|
||||
await Should.ThrowAsync<ArgumentOutOfRangeException>(() =>
|
||||
HistoryReadPaging.ReadAllAsync(server.ReadPageAsync, Values, max, Node, CancellationToken.None));
|
||||
server.Calls.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Review m2: server pages that overshoot the cap are trimmed to it (60 + 60 with a cap of 100 is 100).</summary>
|
||||
[Fact]
|
||||
public async Task The_result_is_trimmed_to_the_cap()
|
||||
{
|
||||
var server = new ScriptedServer((60, [1]), (60, [2]));
|
||||
|
||||
var values = await HistoryReadPaging.ReadAllAsync(server.ReadPageAsync, Values, maxValues: 100, Node,
|
||||
CancellationToken.None);
|
||||
|
||||
values.Count.ShouldBe(100);
|
||||
server.Calls.Last().Release.ShouldBeTrue(); // the outstanding cursor is still released
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review m1: a read cancelled between pages releases the outstanding cursor (best effort, not on the caller's
|
||||
/// cancelled token) and still surfaces the cancellation. Positive control: the release IS recorded.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_read_cancelled_mid_paging_releases_the_cursor_and_rethrows()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
var calls = new List<(byte[]? Cp, bool Release, bool TokenCancelled)>();
|
||||
Task<HistoryReadResult?> Read(byte[]? cp, bool release, CancellationToken ct)
|
||||
{
|
||||
calls.Add((cp, release, ct.IsCancellationRequested));
|
||||
if (release) return Task.FromResult<HistoryReadResult?>(new HistoryReadResult { StatusCode = StatusCodes.Good });
|
||||
if (cp != null) { cts.Cancel(); ct.ThrowIfCancellationRequested(); }
|
||||
var data = new HistoryData();
|
||||
data.DataValues.Add(new DataValue(new Variant(1)));
|
||||
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
|
||||
{
|
||||
StatusCode = StatusCodes.Good, ContinuationPoint = [5], HistoryData = new ExtensionObject(data)
|
||||
});
|
||||
}
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(() =>
|
||||
HistoryReadPaging.ReadAllAsync(Read, Values, maxValues: 10, Node, cts.Token));
|
||||
|
||||
var release = calls.Where(c => c.Release).ShouldHaveSingleItem();
|
||||
release.Cp.ShouldBe(new byte[] { 5 });
|
||||
release.TokenCancelled.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>A cancellation that lands while the cap release is in flight does not lose the values read.</summary>
|
||||
[Fact]
|
||||
public async Task The_cap_release_does_not_ride_the_callers_token()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
var releaseSawCancelled = (bool?)null;
|
||||
Task<HistoryReadResult?> Read(byte[]? cp, bool release, CancellationToken ct)
|
||||
{
|
||||
if (release)
|
||||
{
|
||||
releaseSawCancelled = ct.IsCancellationRequested;
|
||||
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult { StatusCode = StatusCodes.Good });
|
||||
}
|
||||
var data = new HistoryData();
|
||||
data.DataValues.Add(new DataValue(new Variant(1)));
|
||||
cts.Cancel(); // the caller gives up right as the capped page arrives
|
||||
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
|
||||
{
|
||||
StatusCode = StatusCodes.Good, ContinuationPoint = [5], HistoryData = new ExtensionObject(data)
|
||||
});
|
||||
}
|
||||
|
||||
var values = await HistoryReadPaging.ReadAllAsync(Read, Values, maxValues: 1, Node, cts.Token);
|
||||
|
||||
values.Count.ShouldBe(1);
|
||||
releaseSawCancelled.ShouldBe(false);
|
||||
}
|
||||
|
||||
/// <summary>A failed release is best effort: the values already read are still returned.</summary>
|
||||
[Fact]
|
||||
public async Task A_failed_release_does_not_lose_the_values_read()
|
||||
{
|
||||
var calls = 0;
|
||||
Task<HistoryReadResult?> Read(byte[]? cp, bool release, CancellationToken ct)
|
||||
{
|
||||
calls++;
|
||||
if (release) throw new ServiceResultException(StatusCodes.BadSessionClosed);
|
||||
var data = new HistoryData();
|
||||
data.DataValues.Add(new DataValue(new Variant(1)));
|
||||
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
|
||||
{
|
||||
StatusCode = StatusCodes.Good, ContinuationPoint = [9], HistoryData = new ExtensionObject(data)
|
||||
});
|
||||
}
|
||||
|
||||
var values = await HistoryReadPaging.ReadAllAsync(Read, Values, maxValues: 1, Node, CancellationToken.None);
|
||||
|
||||
values.Count.ShouldBe(1);
|
||||
calls.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>A refused page still surfaces as an exception, not an empty read.</summary>
|
||||
[Fact]
|
||||
public async Task A_refused_page_throws()
|
||||
{
|
||||
Task<HistoryReadResult?> Read(byte[]? cp, bool release, CancellationToken ct) =>
|
||||
Task.FromResult<HistoryReadResult?>(new HistoryReadResult
|
||||
{
|
||||
StatusCode = StatusCodes.BadHistoryOperationUnsupported
|
||||
});
|
||||
|
||||
await Should.ThrowAsync<ServiceResultException>(() =>
|
||||
HistoryReadPaging.ReadAllAsync(Read, Values, maxValues: 10, Node, CancellationToken.None));
|
||||
}
|
||||
}
|
||||
@@ -132,10 +132,10 @@ public class ModelConstructionTests
|
||||
((int)ConnectionState.Reconnecting).ShouldBe(3);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AggregateType enum has the expected number of values.</summary>
|
||||
/// <summary>AggregateType holds the seventeen served aggregates (the parity test pins which).</summary>
|
||||
[Fact]
|
||||
public void AggregateType_Enum_HasExpectedValues()
|
||||
{
|
||||
Enum.GetValues<AggregateType>().Length.ShouldBe(12);
|
||||
Enum.GetValues<AggregateType>().Length.ShouldBe(17);
|
||||
}
|
||||
}
|
||||
@@ -384,10 +384,12 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that object nodes trigger child-detection checks so the client can mark expandable branches.
|
||||
/// R08 3.6: browsing a level issues ONE Browse (plus BrowseNext pages) however many Object children it holds. Child
|
||||
/// detection is lazy: an Object may have children and is confirmed when it is expanded/recursed, instead of one
|
||||
/// extra Browse per Object child (~50 000 calls for a recursive subscribe over a 50k-node raw tree).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task BrowseAsync_ObjectNode_ChecksHasChildren()
|
||||
public async Task BrowseAsync_ObjectChildren_MayHaveChildren_WithoutAPerChildBrowse()
|
||||
{
|
||||
var session = new FakeSessionAdapter
|
||||
{
|
||||
@@ -398,17 +400,29 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
NodeId = new ExpandedNodeId("ns=2;s=Folder1"),
|
||||
DisplayName = new LocalizedText("Folder1"),
|
||||
NodeClass = NodeClass.Object
|
||||
},
|
||||
new ReferenceDescription
|
||||
{
|
||||
NodeId = new ExpandedNodeId("ns=2;s=Folder2"),
|
||||
DisplayName = new LocalizedText("Folder2"),
|
||||
NodeClass = NodeClass.Object
|
||||
},
|
||||
new ReferenceDescription
|
||||
{
|
||||
NodeId = new ExpandedNodeId("ns=2;s=Tag"),
|
||||
DisplayName = new LocalizedText("Tag"),
|
||||
NodeClass = NodeClass.Variable
|
||||
}
|
||||
],
|
||||
HasChildrenResponse = true
|
||||
]
|
||||
};
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
|
||||
var results = await _service.BrowseAsync();
|
||||
|
||||
results[0].HasChildren.ShouldBeTrue();
|
||||
session.HasChildrenCount.ShouldBe(1);
|
||||
results.Select(r => r.HasChildren).ShouldBe([true, true, false]);
|
||||
session.BrowseCount.ShouldBe(1);
|
||||
session.BrowseNextCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1432,6 +1446,97 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
_service.CurrentConnectionInfo!.EndpointUrl.ShouldBe("opc.tcp://backup:4840");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R08 4.5: a ConnectAsync on an already-connected client is refused instead of overwriting (and leaking) the live
|
||||
/// session. The positive control: after a disconnect the same client connects again.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ConnectAsync_WhenAlreadyConnected_IsRefused_AndKeepsTheLiveSession()
|
||||
{
|
||||
var session1 = new FakeSessionAdapter();
|
||||
_sessionFactory.EnqueueSession(session1);
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => _service.ConnectAsync(ValidSettings()));
|
||||
|
||||
_sessionFactory.CreateCallCount.ShouldBe(1);
|
||||
session1.Disposed.ShouldBeFalse();
|
||||
_service.IsConnected.ShouldBeTrue();
|
||||
|
||||
await _service.DisconnectAsync();
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
_sessionFactory.CreateCallCount.ShouldBe(2);
|
||||
_service.IsConnected.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R08 4.5: a SubscribeAsync in flight when a keep-alive failover starts must not be lost. Failover runs under the
|
||||
/// subscription gate, so it waits for the in-flight subscribe to record its item and then replays it onto the new
|
||||
/// session. Before the fix, failover nulled the subscription state outside the lock and finished first; the subscribe
|
||||
/// then completed onto the dying session's subscription and was never replayed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_in_flight_during_failover_is_replayed_onto_the_new_session()
|
||||
{
|
||||
var session1 = new FakeSessionAdapter { EndpointUrl = "opc.tcp://primary:4840" };
|
||||
var session2 = new FakeSessionAdapter { EndpointUrl = "opc.tcp://backup:4840" };
|
||||
_sessionFactory.EnqueueSession(session1);
|
||||
_sessionFactory.EnqueueSession(session2);
|
||||
var settings = ValidSettings("opc.tcp://primary:4840");
|
||||
settings.FailoverUrls = ["opc.tcp://backup:4840"];
|
||||
await _service.ConnectAsync(settings);
|
||||
|
||||
var gate = new TaskCompletionSource();
|
||||
session1.CreateSubscriptionGate = gate;
|
||||
var subscribe = _service.SubscribeAsync(new NodeId("ns=2;s=Racer"), 1000);
|
||||
await session1.CreateSubscriptionEntered.Task.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
session1.SimulateKeepAlive(false);
|
||||
var failover = _service.LastFailover.ShouldNotBeNull();
|
||||
|
||||
gate.SetResult();
|
||||
await subscribe.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
await failover.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
_service.CurrentConnectionInfo!.EndpointUrl.ShouldBe("opc.tcp://backup:4840");
|
||||
var replayed = session2.CreatedSubscriptions.ShouldHaveSingleItem();
|
||||
replayed.AddDataChangeCount.ShouldBe(1);
|
||||
|
||||
// And it is the live subscription: unsubscribing removes it from session2's subscription, not session1's.
|
||||
await _service.UnsubscribeAsync(new NodeId("ns=2;s=Racer"));
|
||||
replayed.RemoveCount.ShouldBe(1);
|
||||
replayed.ActiveHandles.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A DisconnectAsync issued while a failover is still connecting wins: the failover disposes the session it just
|
||||
/// opened instead of resurrecting the connection. The positive control is the plain failover test above, where the
|
||||
/// same gated attempt ends Connected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DisconnectAsync_during_failover_is_not_undone_by_the_failover()
|
||||
{
|
||||
var session1 = new FakeSessionAdapter { EndpointUrl = "opc.tcp://primary:4840" };
|
||||
var session2 = new FakeSessionAdapter { EndpointUrl = "opc.tcp://backup:4840" };
|
||||
_sessionFactory.EnqueueSession(session1);
|
||||
_sessionFactory.EnqueueSession(session2);
|
||||
var settings = ValidSettings("opc.tcp://primary:4840");
|
||||
settings.FailoverUrls = ["opc.tcp://backup:4840"];
|
||||
await _service.ConnectAsync(settings);
|
||||
|
||||
var gate = new TaskCompletionSource();
|
||||
_sessionFactory.CreateGate = gate;
|
||||
session1.SimulateKeepAlive(false);
|
||||
var failover = _service.LastFailover.ShouldNotBeNull();
|
||||
|
||||
await _service.DisconnectAsync();
|
||||
gate.SetResult();
|
||||
await failover.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
_service.IsConnected.ShouldBeFalse();
|
||||
session2.Disposed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Client.Shared-005: concurrent subscribe/unsubscribe calls mutating the
|
||||
/// active-subscription bookkeeping must not corrupt the dictionary or throw.
|
||||
|
||||
+2
@@ -21,6 +21,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Client\ZB.MOM.WW.OtOpcUa.Client.Shared\ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
||||
<!-- Test-only: the served-aggregate parity oracle (AggregateFamilies.Served). -->
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common\ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -141,7 +141,16 @@ public class HistoryViewModelTests
|
||||
public void AggregateTypes_ContainsNullForRaw()
|
||||
{
|
||||
_vm.AggregateTypes.ShouldContain((AggregateType?)null);
|
||||
_vm.AggregateTypes.Count.ShouldBe(13); // null + 12 enum values
|
||||
_vm.AggregateTypes.Count.ShouldBe(Enum.GetValues<AggregateType>().Length + 1); // null + every aggregate
|
||||
}
|
||||
|
||||
/// <summary>The picker offers every client aggregate (the served set), including Range/Delta/PercentGood/...</summary>
|
||||
[Fact]
|
||||
public void AggregateTypes_offers_every_aggregate()
|
||||
{
|
||||
_vm.AggregateTypes.Where(a => a != null).Select(a => a!.Value)
|
||||
.ShouldBe(Enum.GetValues<AggregateType>(), ignoreOrder: true);
|
||||
_vm.AggregateTypes.ShouldContain(AggregateType.PercentGood);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the Clear method resets the view model state.</summary>
|
||||
|
||||
@@ -111,6 +111,70 @@ public class SubscriptionsViewModelTests
|
||||
_vm.ActiveSubscriptions[0].Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R08 3.6: notifications are routed through a NodeId index, not a scan of every row. The index follows every
|
||||
/// mutation of <see cref="SubscriptionsViewModel.ActiveSubscriptions"/> (add, remove, replace, clear).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NodeId_index_follows_every_collection_mutation()
|
||||
{
|
||||
var a = new SubscriptionItemViewModel("ns=2;s=A", 1000);
|
||||
var b = new SubscriptionItemViewModel("ns=2;s=B", 1000);
|
||||
var c = new SubscriptionItemViewModel("ns=2;s=C", 1000);
|
||||
_vm.ActiveSubscriptions.Add(a);
|
||||
_vm.ActiveSubscriptions.Add(b);
|
||||
_vm.ActiveSubscriptions.Add(c);
|
||||
_vm.IndexedNodeCount.ShouldBe(3);
|
||||
|
||||
_vm.ActiveSubscriptions.Remove(b);
|
||||
_vm.IndexedNodeCount.ShouldBe(2);
|
||||
|
||||
_vm.ActiveSubscriptions[0] = new SubscriptionItemViewModel("ns=2;s=D", 1000); // replaces A
|
||||
Raise("ns=2;s=A", 1);
|
||||
Raise("ns=2;s=D", 2);
|
||||
a.Value.ShouldBeNull();
|
||||
_vm.ActiveSubscriptions[0].Value.ShouldBe("2");
|
||||
|
||||
_vm.ActiveSubscriptions.Clear();
|
||||
_vm.IndexedNodeCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>A removed row stops receiving values while its sibling (the positive control) still does.</summary>
|
||||
[Fact]
|
||||
public void DataChanged_after_remove_updates_only_the_remaining_rows()
|
||||
{
|
||||
var a = new SubscriptionItemViewModel("ns=2;s=A", 1000);
|
||||
var b = new SubscriptionItemViewModel("ns=2;s=B", 1000);
|
||||
_vm.ActiveSubscriptions.Add(a);
|
||||
_vm.ActiveSubscriptions.Add(b);
|
||||
_vm.ActiveSubscriptions.Remove(a);
|
||||
|
||||
Raise("ns=2;s=A", 7);
|
||||
Raise("ns=2;s=B", 42);
|
||||
|
||||
a.Value.ShouldBeNull();
|
||||
b.Value.ShouldBe("42");
|
||||
}
|
||||
|
||||
/// <summary>Two rows for the same node (no dedup on the manual-add path) both update, as before.</summary>
|
||||
[Fact]
|
||||
public void DataChanged_updates_every_row_for_the_node()
|
||||
{
|
||||
var first = new SubscriptionItemViewModel("ns=2;s=A", 1000);
|
||||
var second = new SubscriptionItemViewModel("ns=2;s=A", 500);
|
||||
_vm.ActiveSubscriptions.Add(first);
|
||||
_vm.ActiveSubscriptions.Add(second);
|
||||
|
||||
Raise("ns=2;s=A", 5);
|
||||
|
||||
first.Value.ShouldBe("5");
|
||||
second.Value.ShouldBe("5");
|
||||
}
|
||||
|
||||
private void Raise(string nodeId, int value) =>
|
||||
_service.RaiseDataChanged(new DataChangedEventArgs(nodeId,
|
||||
new DataValue(new Variant(value), StatusCodes.Good, DateTime.UtcNow)));
|
||||
|
||||
/// <summary>Verifies that Clear removes all subscriptions.</summary>
|
||||
[Fact]
|
||||
public void Clear_RemovesAllSubscriptions()
|
||||
|
||||
+93
-5
@@ -15,12 +15,27 @@ public sealed class DriverArrayWriteSupportTests
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"region\":\"Coils\",\"address\":0,\"dataType\":\"Bool\"" + Arr2 + "}", true)]
|
||||
[InlineData("{\"region\":\"Coils\",\"address\":0" + Arr2 + "}", true)] // FC15 ignores dataType
|
||||
[InlineData("{\"region\":\"Coils\",\"address\":0" + Arr2 + "}", true)] // absent dataType ⇒ Bool
|
||||
[InlineData("{\"region\":\"Coils\",\"address\":0,\"dataType\":\"Int16\"" + Arr2 + "}", true)] // a bit region COERCES any type to Bool
|
||||
[InlineData("{\"region\":\"Coils\",\"address\":0,\"dataType\":\"Int17\"" + Arr2 + "}", false)] // ...but a typo refuses the tag
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Float32\"" + Arr2 + "}", true)]
|
||||
[InlineData("{\"region\":\"holdingregisters\",\"address\":0,\"dataType\":\"bcd16\"" + Arr2 + "}", true)] // case-insensitive, like the driver
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0" + Arr2 + "}", true)] // dataType ⇒ Int16
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"BitInRegister\"" + Arr2 + "}", false)]
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"String\"" + Arr2 + "}", false)]
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"BitInRegister\"" + Arr2 + "}", true)] // #741 U3
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"BitInRegister\",\"bitIndex\":15" + Arr2 + "}", true)]
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"BitInRegister\",\"bitIndex\":16" + Arr2 + "}", false)] // factory refuses
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"String\",\"stringLength\":4" + Arr2 + "}", true)] // #741 U3
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"String\"" + Arr2 + "}", false)] // no stringLength ⇒ refused
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"String\",\"stringLength\":0" + Arr2 + "}", false)]
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Int16\",\"byteOrder\":\"Sideways\"" + Arr2 + "}", false)] // strict enum
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"String\",\"stringLength\":4,\"stringByteOrder\":\"Middle\"" + Arr2 + "}", false)]
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65536,\"dataType\":\"Int16\"" + Arr2 + "}", false)] // not a register address
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":1.5,\"dataType\":\"Int16\"" + Arr2 + "}", false)]
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65535,\"dataType\":\"BitInRegister\",\"isArray\":true,\"arrayLength\":1}", true)]
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65535,\"dataType\":\"BitInRegister\"" + Arr2 + "}", false)] // span runs past 65535
|
||||
[InlineData("{\"addressString\":\"40001:S::2\"" + Arr2 + "}", false)] // string-only: family unknown at compose time
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"addressString\":\"40001:S::2\"" + Arr2 + "}", false)] // region alone is not the keys
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"addressString\":\"30001\",\"dataType\":\"Int16\"" + Arr2 + "}", true)] // keys win
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Bool\"" + Arr2 + "}", false)]
|
||||
[InlineData("{\"region\":\"InputRegisters\",\"address\":0,\"dataType\":\"Int16\"" + Arr2 + "}", false)] // read-only region
|
||||
[InlineData("{\"region\":\"DiscreteInputs\",\"address\":0" + Arr2 + "}", false)]
|
||||
@@ -59,11 +74,50 @@ public sealed class DriverArrayWriteSupportTests
|
||||
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}")
|
||||
// A configured cap above the FC16 protocol ceiling (123) never lifts it: a conformant device refuses 124
|
||||
// registers (and past 127 the driver's one-byte byte-count field wraps), so the write always fails.
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray("Int16", 123), "{\"maxRegistersPerWrite\":200}")
|
||||
.ShouldBeTrue();
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray("Int16", 124), "{\"maxRegistersPerWrite\":200}")
|
||||
.ShouldBeFalse();
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray("BitInRegister", 124), "{\"maxRegistersPerWrite\":200}")
|
||||
.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>A BitInRegister[] write is a read-modify-write: one unchunked FC03 of the whole span first, so its
|
||||
/// length is also bounded by <c>maxRegistersPerRead</c> (smallest positive value across the configs; absent / 0 =>
|
||||
/// 125, and never above FC03's protocol ceiling of 125). The other register shapes never read on write.</summary>
|
||||
[Fact]
|
||||
public void Modbus_bit_in_register_arrays_respect_the_read_cap()
|
||||
{
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 64), "{\"maxRegistersPerRead\":64}").ShouldBeTrue();
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 65), "{\"maxRegistersPerRead\":64}").ShouldBeFalse();
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 65), "{\"maxRegistersPerRead\":100}", "{\"MaxRegistersPerRead\":64}")
|
||||
.ShouldBeFalse();
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 123), "{\"maxRegistersPerRead\":0}").ShouldBeTrue();
|
||||
// A numeric array of the same length is not read on write, so the read cap does not apply to it.
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray("Int16", 65), "{\"maxRegistersPerRead\":64}").ShouldBeTrue();
|
||||
// With the write cap lifted, the read cap is the bound.
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 100),
|
||||
"{\"maxRegistersPerRead\":100,\"maxRegistersPerWrite\":200}").ShouldBeTrue();
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 101),
|
||||
"{\"maxRegistersPerRead\":100,\"maxRegistersPerWrite\":200}").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Every shape's span must end at or before address 65535 - a conformant device refuses a write past it
|
||||
/// (exception 02).</summary>
|
||||
[Theory]
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65530,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":6}", true)]
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65530,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":7}", false)]
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65532,\"dataType\":\"Int32\",\"isArray\":true,\"arrayLength\":2}", true)]
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65533,\"dataType\":\"Int32\",\"isArray\":true,\"arrayLength\":2}", false)]
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65534,\"dataType\":\"String\",\"stringLength\":4,\"isArray\":true,\"arrayLength\":1}", true)]
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65535,\"dataType\":\"String\",\"stringLength\":4,\"isArray\":true,\"arrayLength\":1}", false)]
|
||||
[InlineData("{\"region\":\"Coils\",\"address\":65000,\"isArray\":true,\"arrayLength\":536}", true)]
|
||||
[InlineData("{\"region\":\"Coils\",\"address\":65000,\"isArray\":true,\"arrayLength\":537}", false)]
|
||||
public void Modbus_array_spans_end_within_the_address_space(string tagConfig, bool expected) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tagConfig).ShouldBe(expected);
|
||||
|
||||
/// <summary>FC15 carries at most 1968 coils and the driver sends one request, so a longer coil array is not
|
||||
/// writable.</summary>
|
||||
[Fact]
|
||||
@@ -75,6 +129,40 @@ public sealed class DriverArrayWriteSupportTests
|
||||
"{\"region\":\"Coils\",\"address\":0,\"isArray\":true,\"arrayLength\":1969}").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>#741 U3 — String[] and BitInRegister[] arrays ride the same FC16 cap: <c>(stringLength+1)/2</c> registers
|
||||
/// per String element, one per BitInRegister element.</summary>
|
||||
[Theory]
|
||||
[InlineData("\"dataType\":\"String\",\"stringLength\":4", 61, true)] // 122 registers
|
||||
[InlineData("\"dataType\":\"String\",\"stringLength\":4", 62, false)] // 124
|
||||
[InlineData("\"dataType\":\"String\",\"stringLength\":5", 41, true)] // 3 registers each: 123
|
||||
[InlineData("\"dataType\":\"String\",\"stringLength\":5", 42, false)] // 126
|
||||
[InlineData("\"dataType\":\"BitInRegister\"", 123, true)]
|
||||
[InlineData("\"dataType\":\"BitInRegister\"", 124, false)]
|
||||
public void Modbus_string_and_bit_arrays_respect_the_write_cap(string typeKeys, int length, bool expected) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16",
|
||||
$"{{\"region\":\"HoldingRegisters\",\"address\":0,{typeKeys},\"isArray\":true,\"arrayLength\":{length}}}").ShouldBe(expected);
|
||||
|
||||
/// <summary>Elements CONVERT (ScadaBridge's <c>int[]</c> to an Int16 node), so the Modbus row keys on no exact node
|
||||
/// DataType; it leaves out only a String or DateTime node over a numeric / bit element (a DateTime never converts; a String converts only when its text happens to
|
||||
/// parse, which the server cannot promise). A
|
||||
/// String element takes any node type (each element is <c>Convert.ToString</c>'d).</summary>
|
||||
[Theory]
|
||||
[InlineData("Int16", "Int16", true)]
|
||||
[InlineData("Int16", "Int32", true)]
|
||||
[InlineData("Int16", "Double", true)]
|
||||
[InlineData("Int16", "Boolean", true)]
|
||||
[InlineData("Int16", "String", false)]
|
||||
[InlineData("Int16", "DateTime", false)]
|
||||
[InlineData("BitInRegister", "Boolean", true)]
|
||||
[InlineData("BitInRegister", "String", false)]
|
||||
[InlineData("String", "String", true)]
|
||||
[InlineData("String", "Int16", true)]
|
||||
[InlineData("String", "DateTime", true)]
|
||||
[InlineData("String", null, true)]
|
||||
public void Modbus_rows_leave_out_only_node_types_that_never_convert(string element, string? nodeType, bool expected) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, nodeType,
|
||||
$"{{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"{element}\",\"stringLength\":4" + Arr2 + "}").ShouldBe(expected);
|
||||
|
||||
private static string RegisterArray(string dataType, int length) =>
|
||||
$"{{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"{dataType}\",\"isArray\":true,\"arrayLength\":{length}}}";
|
||||
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
using CliFx.Infrastructure;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 U7: the AbCip probe/read/write commands had no <see cref="OperationCanceledException"/> arm, so a
|
||||
/// Ctrl-C printed a stack trace. A command cancelled by the operator now exits cleanly with <c>Cancelled.</c>.
|
||||
/// The gateway is TEST-NET (never answers), so nothing is sent to a real device.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CancellationHandlingTests
|
||||
{
|
||||
private const string Blackhole = "ab://192.0.2.1/1,0";
|
||||
|
||||
private static async Task<string> RunCancelledAsync(AbCipCommandBase command)
|
||||
{
|
||||
using var console = new FakeInMemoryConsole();
|
||||
console.RequestCancellation();
|
||||
|
||||
await Should.NotThrowAsync(async () => await command.ExecuteAsync(console));
|
||||
return console.ReadErrorString();
|
||||
}
|
||||
|
||||
/// <summary>A cancelled read prints Cancelled. and does not throw.</summary>
|
||||
[Fact]
|
||||
public async Task A_cancelled_read_prints_Cancelled()
|
||||
{
|
||||
(await RunCancelledAsync(new ReadCommand { Gateway = Blackhole, TagPath = "Motor01" }))
|
||||
.ShouldContain("Cancelled.");
|
||||
}
|
||||
|
||||
/// <summary>A cancelled probe prints Cancelled. and does not throw.</summary>
|
||||
[Fact]
|
||||
public async Task A_cancelled_probe_prints_Cancelled()
|
||||
{
|
||||
(await RunCancelledAsync(new ProbeCommand { Gateway = Blackhole, TagPath = "Motor01" }))
|
||||
.ShouldContain("Cancelled.");
|
||||
}
|
||||
|
||||
/// <summary>A cancelled write prints Cancelled. and does not throw.</summary>
|
||||
[Fact]
|
||||
public async Task A_cancelled_write_prints_Cancelled()
|
||||
{
|
||||
(await RunCancelledAsync(new WriteCommand { Gateway = Blackhole, TagPath = "Motor01", Value = "1" }))
|
||||
.ShouldContain("Cancelled.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Text;
|
||||
using CliFx.Infrastructure;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 U7, shared half: arrays render their elements (not <c>System.Int32[]</c>), Ctrl-C prints
|
||||
/// <c>Cancelled.</c>, and the subscribe change-line writer is serialised and never throws into the poll loop.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CliHelpersU7Tests
|
||||
{
|
||||
private sealed record Handle(string DiagnosticId) : ISubscriptionHandle;
|
||||
|
||||
private static DataChangeEventArgs Change(string reference, object? value) =>
|
||||
new(new Handle("h"), reference, new DataValueSnapshot(value, 0u, DateTime.UtcNow, DateTime.UtcNow));
|
||||
|
||||
/// <summary>An array value renders as its elements, each formatted like a scalar.</summary>
|
||||
[Fact]
|
||||
public void FormatValue_renders_array_elements()
|
||||
{
|
||||
SnapshotFormatter.FormatValue(new[] { 1, 2, 3 }).ShouldBe("[1, 2, 3]");
|
||||
SnapshotFormatter.FormatValue(new[] { 1.5f, -2f }).ShouldBe("[1.5, -2]");
|
||||
SnapshotFormatter.FormatValue(new[] { true, false }).ShouldBe("[true, false]");
|
||||
SnapshotFormatter.FormatValue(new[] { "a", "b" }).ShouldBe("[\"a\", \"b\"]");
|
||||
SnapshotFormatter.FormatValue(new List<short> { 7 }).ShouldBe("[7]");
|
||||
SnapshotFormatter.FormatValue(Array.Empty<int>()).ShouldBe("[]");
|
||||
}
|
||||
|
||||
/// <summary>A string is still a scalar, not a sequence of chars (positive control for the array arm).</summary>
|
||||
[Fact]
|
||||
public void FormatValue_keeps_a_string_scalar()
|
||||
{
|
||||
SnapshotFormatter.FormatValue("abc").ShouldBe("\"abc\"");
|
||||
}
|
||||
|
||||
/// <summary>Operator Ctrl-C prints Cancelled. instead of a stack trace.</summary>
|
||||
[Fact]
|
||||
public async Task RunCancellableAsync_operator_cancellation_prints_Cancelled()
|
||||
{
|
||||
using var console = new FakeInMemoryConsole();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
await Should.NotThrowAsync(async () =>
|
||||
await DriverCommandBase.RunCancellableAsync(console, cts.Token, () => Task.FromCanceled(cts.Token)));
|
||||
|
||||
console.ReadErrorString().ShouldContain("Cancelled.");
|
||||
}
|
||||
|
||||
/// <summary>A cancellation the operator did not request still propagates.</summary>
|
||||
[Fact]
|
||||
public async Task RunCancellableAsync_unrequested_cancellation_propagates()
|
||||
{
|
||||
using var console = new FakeInMemoryConsole();
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||
await DriverCommandBase.RunCancellableAsync(console, CancellationToken.None,
|
||||
() => throw new OperationCanceledException()));
|
||||
}
|
||||
|
||||
/// <summary>A change line carries the reference, the formatted value and the status.</summary>
|
||||
[Fact]
|
||||
public void ChangeLineWriter_writes_one_formatted_line_per_change()
|
||||
{
|
||||
var output = new StringWriter();
|
||||
var write = DriverCommandBase.CreateChangeLineWriter(output, new object());
|
||||
|
||||
write(this, Change("Tag:DInt", new[] { 4, 5 }));
|
||||
|
||||
var line = output.ToString().TrimEnd();
|
||||
line.ShouldContain("Tag:DInt = [4, 5] (0x00000000 (Good))");
|
||||
}
|
||||
|
||||
/// <summary>Concurrent poll-thread changes produce whole lines, never interleaved fragments.</summary>
|
||||
[Fact]
|
||||
public async Task ChangeLineWriter_serialises_concurrent_writes()
|
||||
{
|
||||
var output = new StringWriter(new StringBuilder());
|
||||
var write = DriverCommandBase.CreateChangeLineWriter(output, new object());
|
||||
|
||||
await Task.WhenAll(Enumerable.Range(0, 8).Select(t => Task.Run(() =>
|
||||
{
|
||||
for (var i = 0; i < 200; i++) write(this, Change($"T{t}", i));
|
||||
})));
|
||||
|
||||
var lines = output.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
|
||||
lines.Length.ShouldBe(1600);
|
||||
lines.ShouldAllBe(l => l.StartsWith('[') && l.EndsWith("(Good))"));
|
||||
}
|
||||
|
||||
/// <summary>A failing console (closed pipe) is swallowed so it cannot fault the driver's poll loop.</summary>
|
||||
[Fact]
|
||||
public void ChangeLineWriter_swallows_a_console_write_failure()
|
||||
{
|
||||
var write = DriverCommandBase.CreateChangeLineWriter(new ThrowingWriter(), new object());
|
||||
|
||||
Should.NotThrow(() => write(this, Change("T", 1)));
|
||||
}
|
||||
|
||||
private sealed class ThrowingWriter : TextWriter
|
||||
{
|
||||
public override Encoding Encoding => Encoding.UTF8;
|
||||
public override void Write(char value) => throw new IOException("pipe closed");
|
||||
public override void WriteLine(string? value) => throw new IOException("pipe closed");
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,15 @@ public sealed class AbStatusCodesTests
|
||||
[InlineData((byte)0x0E, AbStatus.NotAllowed)]
|
||||
[InlineData((byte)0x1E, AbStatus.BadReply)]
|
||||
[InlineData((byte)0x01, AbStatus.BadReply)]
|
||||
[InlineData((byte)0x07, AbStatus.BadReply)] // connection lost: opaque on purpose (AbLegacy #738)
|
||||
// Lane K F1 (#737): the controller answered and refused this request — not a garbled reply.
|
||||
[InlineData((byte)0x1C, AbStatus.Unsupported)] // missing attribute list entry data
|
||||
[InlineData((byte)0x20, AbStatus.Unsupported)] // invalid parameter
|
||||
public void Cip_general_status_maps(byte general, AbStatus expected) =>
|
||||
AbStatusCodes.FromCip(general, ReadOnlySpan<ushort>.Empty).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData((ushort)0x2104, AbStatus.OutOfBounds)] // offset beyond end of the tag (Lane K F1)
|
||||
[InlineData((ushort)0x2105, AbStatus.OutOfBounds)]
|
||||
[InlineData((ushort)0x2107, AbStatus.TypeMismatch)]
|
||||
[InlineData((ushort)0x9999, AbStatus.BadReply)]
|
||||
@@ -37,16 +42,42 @@ public sealed class AbStatusCodesTests
|
||||
|
||||
[Theory]
|
||||
[InlineData((byte)0x00, null, AbStatus.Ok)]
|
||||
[InlineData((byte)0x10, null, AbStatus.Unsupported)]
|
||||
[InlineData((byte)0x20, null, AbStatus.NotFound)]
|
||||
[InlineData((byte)0x30, null, AbStatus.NotAllowed)]
|
||||
[InlineData((byte)0x40, null, AbStatus.DeviceStateConflict)]
|
||||
[InlineData((byte)0xF0, (byte)0x06, AbStatus.NotFound)]
|
||||
[InlineData((byte)0xF0, (byte)0x07, AbStatus.NotFound)]
|
||||
[InlineData((byte)0x10, null, AbStatus.Unsupported)] // illegal command or format
|
||||
// Lane K F3 (AbLegacy F8, review 07 r0 I4), per the DF1/PCCC STS table (1770-6.5.16):
|
||||
[InlineData((byte)0x20, null, AbStatus.BadConnection)] // host has a problem and will not communicate
|
||||
[InlineData((byte)0x30, null, AbStatus.BadConnection)] // remote node host missing / disconnected / shut down
|
||||
[InlineData((byte)0x40, null, AbStatus.DeviceStateConflict)] // host hardware fault (the host answered)
|
||||
[InlineData((byte)0x50, null, AbStatus.NotFound)] // addressing problem or memory protect rungs
|
||||
[InlineData((byte)0x60, null, AbStatus.NotAllowed)] // function not allowed due to command protection
|
||||
[InlineData((byte)0x70, null, AbStatus.DeviceStateConflict)] // processor is in Program mode
|
||||
[InlineData((byte)0x80, null, AbStatus.BadReply)] // compatibility-mode file missing: unclassified
|
||||
[InlineData((byte)0x90, null, AbStatus.BadConnection)] // remote node cannot buffer command
|
||||
[InlineData((byte)0xB0, null, AbStatus.DeviceStateConflict)] // remote node problem due to download
|
||||
[InlineData((byte)0xF0, (byte)0x06, AbStatus.NotFound)] // address does not point to something usable
|
||||
[InlineData((byte)0xF0, (byte)0x07, AbStatus.OutOfBounds)] // file is wrong size
|
||||
[InlineData((byte)0xF0, (byte)0x0A, AbStatus.BadReply)]
|
||||
[InlineData((byte)0xF0, null, AbStatus.BadReply)]
|
||||
public void Pccc_status_maps(byte sts, byte? ext, AbStatus expected) =>
|
||||
AbStatusCodes.FromPccc(sts, ext).ShouldBe(expected);
|
||||
|
||||
/// <summary>
|
||||
/// A dead DH+/DH-485 node behind a bridge answers STS 0x30 (and a busy one 0x90). Neither proves the END
|
||||
/// device answered, so they are the no-reply comms class, never one of the "answered" refusals — that
|
||||
/// classification made such a device count as reachable forever (no backoff, no comms-loss watchdog).
|
||||
/// </summary>
|
||||
/// <param name="sts">The PCCC STS byte.</param>
|
||||
[Theory]
|
||||
[InlineData((byte)0x20)]
|
||||
[InlineData((byte)0x30)]
|
||||
[InlineData((byte)0x90)]
|
||||
public void Pccc_no_reply_statuses_are_never_answered_refusals(byte sts)
|
||||
{
|
||||
var status = AbStatusCodes.FromPccc(sts, null);
|
||||
status.ShouldBe(AbStatus.BadConnection);
|
||||
status.ShouldNotBeOneOf(AbStatus.NotFound, AbStatus.NotAllowed, AbStatus.OutOfBounds,
|
||||
AbStatus.DeviceStateConflict, AbStatus.TooLarge, AbStatus.TooSmall, AbStatus.TypeMismatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ok_is_zero_and_every_error_is_negative()
|
||||
{
|
||||
|
||||
+24
@@ -115,6 +115,30 @@ public sealed class ConnectionManagerServicesTests
|
||||
ex.Status.ShouldBe(AbStatus.BadConnection); // general 0x01 has no specific mapping
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lane K F1 remapped general 0x1C / 0x20 and 0xFF/0x2104 for TAG requests (a per-request refusal). A Forward
|
||||
/// Open refused with one of them is still a connection failure, exactly as before: the whole connection is
|
||||
/// unusable, so it must not read as a per-tag refusal.
|
||||
/// </summary>
|
||||
/// <param name="hex">The refused Forward Open reply.</param>
|
||||
[Theory]
|
||||
[InlineData("DB001C00")]
|
||||
[InlineData("DB002000")]
|
||||
[InlineData("DB00FF010421")]
|
||||
public void Forward_open_refused_with_a_request_level_status_is_still_BadConnection(string hex)
|
||||
{
|
||||
var ex = Should.Throw<ForwardOpenRefusedException>(() =>
|
||||
ConnectionManagerServices.ParseForwardOpenReply(CipReply.Parse(Convert.FromHexString(hex))));
|
||||
ex.Status.ShouldBe(AbStatus.BadConnection);
|
||||
}
|
||||
|
||||
/// <summary>Positive control: a Forward Open refusal that was already specifically mapped keeps its status.</summary>
|
||||
[Fact]
|
||||
public void Forward_open_refused_service_not_supported_stays_Unsupported() =>
|
||||
Should.Throw<ForwardOpenRefusedException>(() =>
|
||||
ConnectionManagerServices.ParseForwardOpenReply(CipReply.Parse(Convert.FromHexString("DB000800"))))
|
||||
.Status.ShouldBe(AbStatus.Unsupported);
|
||||
|
||||
[Fact]
|
||||
public void Refused_forward_open_exception_is_not_aliased_to_the_reply_array()
|
||||
{
|
||||
|
||||
+43
-1
@@ -90,6 +90,32 @@ public sealed class PcccServicesTests
|
||||
[Fact]
|
||||
public void Plc5_write_is_not_shipped_and_flagged() => PcccServices.Plc5WriteVerified.ShouldBeFalse();
|
||||
|
||||
// ---- ParseReply: golden STS replies (Lane K F3 / AbLegacy F8) ----
|
||||
|
||||
/// <summary>
|
||||
/// Wire-level golden replies: the Execute PCCC reply bytes a device (or a bridge in front of it) sends for each
|
||||
/// STS, through <see cref="PcccServices.ParseReply"/>, to the status the drivers classify.
|
||||
/// </summary>
|
||||
/// <param name="stsHex">The reply's STS (+ EXT STS) bytes as hex.</param>
|
||||
/// <param name="expected">The status ParseReply raises.</param>
|
||||
[Theory]
|
||||
[InlineData("20", AbStatus.BadConnection)]
|
||||
[InlineData("30", AbStatus.BadConnection)]
|
||||
[InlineData("90", AbStatus.BadConnection)]
|
||||
[InlineData("50", AbStatus.NotFound)]
|
||||
[InlineData("60", AbStatus.NotAllowed)]
|
||||
[InlineData("70", AbStatus.DeviceStateConflict)]
|
||||
[InlineData("B0", AbStatus.DeviceStateConflict)]
|
||||
[InlineData("F0|06", AbStatus.NotFound)]
|
||||
[InlineData("F0|07", AbStatus.OutOfBounds)]
|
||||
public void ParseReply_golden_sts(string stsHex, AbStatus expected)
|
||||
{
|
||||
var parts = stsHex.Split('|');
|
||||
var hex = Id + "4F" + parts[0] + "9987" + (parts.Length > 1 ? parts[1] : "");
|
||||
Should.Throw<AbTransportException>(() => PcccServices.ParseReply(Reply(hex), 0x8799))
|
||||
.Status.ShouldBe(expected);
|
||||
}
|
||||
|
||||
// ---- ParseReply ----
|
||||
private static CipReply Reply(string hex, byte general = 0) =>
|
||||
new(0x4B, general, [], Hex(hex));
|
||||
@@ -122,7 +148,23 @@ public sealed class PcccServicesTests
|
||||
|
||||
[Fact]
|
||||
public void ParseReply_cip_error_status_throws() =>
|
||||
Should.Throw<AbTransportException>(() => PcccServices.ParseReply(Reply("", 0x08), 1)).Status.ShouldBe(AbStatus.Unsupported);
|
||||
Should.Throw<AbDeviceReplyException>(() => PcccServices.ParseReply(Reply("", 0x08), 1)).Status.ShouldBe(AbStatus.Unsupported);
|
||||
|
||||
/// <summary>Review I2: a status the device REPLIED with is marked as such (envelope CIP status and PCCC STS).</summary>
|
||||
[Fact]
|
||||
public void ParseReply_marks_device_statuses_as_device_replies()
|
||||
{
|
||||
Should.Throw<AbDeviceReplyException>(() => PcccServices.ParseReply(Reply("", 0x20), 1)).Status.ShouldBe(AbStatus.Unsupported);
|
||||
Should.Throw<AbDeviceReplyException>(() => PcccServices.ParseReply(Reply(Id + "4F10 9987"), 0x8799)).Status.ShouldBe(AbStatus.Unsupported);
|
||||
}
|
||||
|
||||
/// <summary>Positive control: a refusal raised by a builder before anything is sent is NOT a device reply.</summary>
|
||||
[Fact]
|
||||
public void A_pre_wire_builder_refusal_is_not_a_device_reply()
|
||||
{
|
||||
var ex = Should.Throw<AbTransportException>(() => PcccServices.SlcTypedRead(1, A("N7:0"), 999));
|
||||
ex.ShouldNotBeOfType<AbDeviceReplyException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseReply_wrong_command_byte_is_BadReply() =>
|
||||
|
||||
@@ -25,3 +25,60 @@ public sealed class CipWriterTests
|
||||
w.ToArray().ShouldBe(new byte[] { 0, 0, 7 }); // nothing was patched
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R07:L5: <see cref="CipWriter"/> works in a buffer rented from <see cref="System.Buffers.ArrayPool{T}"/> and hands
|
||||
/// it back on <see cref="CipWriter.ToArray"/>. The returned array must be the caller's own (a later writer reusing
|
||||
/// the pooled buffer cannot change it), growth must keep every byte, and a writer used after ToArray must refuse
|
||||
/// rather than write into a buffer it no longer owns.
|
||||
/// </summary>
|
||||
public sealed class CipWriterPoolingTests
|
||||
{
|
||||
[Fact]
|
||||
public void Growth_past_the_initial_buffer_keeps_every_byte()
|
||||
{
|
||||
var w = new CipWriter();
|
||||
for (var i = 0; i < 1000; i++) w.U8((byte)i);
|
||||
var bytes = w.ToArray();
|
||||
bytes.Length.ShouldBe(1000);
|
||||
bytes.Select((b, i) => b == (byte)i).ShouldAllBe(ok => ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_returned_array_is_not_affected_by_a_later_writer_reusing_the_pooled_buffer()
|
||||
{
|
||||
var first = new CipWriter().U32(0xAABBCCDD).ToArray();
|
||||
for (var i = 0; i < 50; i++) new CipWriter().U32(0x11111111).U32(0x22222222).ToArray();
|
||||
first.ShouldBe(new byte[] { 0xDD, 0xCC, 0xBB, 0xAA });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToArray_twice_returns_equal_independent_copies()
|
||||
{
|
||||
var w = new CipWriter().U16(0x0102);
|
||||
var a = w.ToArray();
|
||||
var b = w.ToArray();
|
||||
b.ShouldBe(a);
|
||||
b.ShouldNotBeSameAs(a);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Writing_after_ToArray_is_refused()
|
||||
{
|
||||
var w = new CipWriter().U8(1);
|
||||
_ = w.ToArray();
|
||||
Should.Throw<InvalidOperationException>(() => w.U8(2));
|
||||
Should.Throw<InvalidOperationException>(() => w.PatchU16(0, 1));
|
||||
w.Length.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nested_writers_produce_independent_frames()
|
||||
{
|
||||
// The Execute PCCC / encapsulation builders build an inner writer while an outer one is live.
|
||||
var outer = new CipWriter().U8(0xAA);
|
||||
var inner = new CipWriter().U8(0x01).U8(0x02).ToArray();
|
||||
var frame = outer.Bytes(inner).U8(0xBB).ToArray();
|
||||
frame.ShouldBe(new byte[] { 0xAA, 0x01, 0x02, 0xBB });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +297,11 @@ public sealed class NativeAbCipTagRuntimeTests
|
||||
[InlineData("D2000A00", AbStatus.OutOfBounds)]
|
||||
[InlineData("D2000F00", AbStatus.NotAllowed)]
|
||||
[InlineData("D200FF010521", AbStatus.OutOfBounds)]
|
||||
// Lane K F1 (#737): these used to arrive as BadReply, i.e. a comms fault (Degraded + evict) for a tag the
|
||||
// controller had simply refused. AbCipRefusals.IsDeviceRefusal classifies the new statuses with no driver change.
|
||||
[InlineData("D2001C00", AbStatus.Unsupported)] // missing attribute list entry data
|
||||
[InlineData("D2002000", AbStatus.Unsupported)] // invalid parameter
|
||||
[InlineData("D200FF010421", AbStatus.OutOfBounds)] // offset beyond end of the tag
|
||||
public async Task A_refused_tag_completes_initialize_and_reports_the_refusal_on_read(string reply, AbStatus expected)
|
||||
{
|
||||
var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString(reply)) };
|
||||
|
||||
+19
-3
@@ -220,14 +220,30 @@ public sealed class NativeAbLegacyTagRuntimeTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task STS_0x20_on_initialize_is_NotFound()
|
||||
public async Task STS_0x50_on_initialize_is_NotFound()
|
||||
{
|
||||
await using var h = new Harness("slc500", "N99:0").Responder((_, _) => (0x20, null, []));
|
||||
await using var h = new Harness("slc500", "N99:0").Responder((_, _) => (0x50, null, []));
|
||||
var ex = await Should.ThrowAsync<AbTransportException>(() => h.Runtime.InitializeAsync(CancellationToken.None));
|
||||
ex.Status.ShouldBe(AbStatus.NotFound);
|
||||
h.Runtime.GetStatus().ShouldBe((int)AbStatus.NotFound);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lane K F3 (F8): STS 0x20 / 0x30 come from the host or a bridge when the END device cannot be reached, so they
|
||||
/// are the no-reply class (BadConnection), not an answered refusal.
|
||||
/// </summary>
|
||||
/// <param name="sts">The PCCC STS.</param>
|
||||
[Theory]
|
||||
[InlineData((byte)0x20)]
|
||||
[InlineData((byte)0x30)]
|
||||
public async Task STS_from_a_bridge_for_an_unreachable_node_is_BadConnection(byte sts)
|
||||
{
|
||||
await using var h = new Harness("slc500", "N7:0").Responder((_, _) => (sts, null, []));
|
||||
var ex = await Should.ThrowAsync<AbTransportException>(() => h.Runtime.InitializeAsync(CancellationToken.None));
|
||||
ex.Status.ShouldBe(AbStatus.BadConnection);
|
||||
h.Runtime.GetStatus().ShouldBe((int)AbStatus.BadConnection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_performs_exactly_one_read()
|
||||
{
|
||||
@@ -305,7 +321,7 @@ public sealed class NativeAbLegacyTagRuntimeTests
|
||||
public async Task A_failed_second_write_chunk_is_reported_as_failed_not_Good()
|
||||
{
|
||||
await using var h = new Harness("slc500", "N7:0", count: 130).Responder((_, n) =>
|
||||
n == 1 ? (0x30, null, []) : (0x00, null, []));
|
||||
n == 1 ? (0x60, null, []) : (0x00, null, []));
|
||||
var ex = await Should.ThrowAsync<AbTransportException>(() => h.Runtime.WriteAsync(CancellationToken.None));
|
||||
ex.Status.ShouldBe(AbStatus.NotAllowed);
|
||||
h.Runtime.GetStatus().ShouldBe((int)AbStatus.NotAllowed);
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Lane K F3 (AbLegacy F8, review 07 r0 I4), end to end over a real socket with the PRODUCTION
|
||||
/// <see cref="NativeAbLegacyTagFactory"/>: a bridge (DHRIO / 1761-NET-ENI) in front of a dead DH+ node answers every
|
||||
/// PCCC request with STS 0x30 "remote node host missing". That is NOT the device answering, so the driver must treat
|
||||
/// it as a comms fault — Degraded, <c>LastSuccessfulRead</c> not advanced — or the comms-loss watchdog never fires
|
||||
/// for it. The positive control is a live device refusing a tag (STS 0x60, command protection): per-tag Bad, Healthy,
|
||||
/// <c>LastSuccessfulRead</c> advanced.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class NativePcccBridgeStatusHealthTests
|
||||
{
|
||||
private static readonly byte[] FixedId = [0x07, 0x3D, 0xF3, 0x45, 0x43, 0x50, 0x21];
|
||||
|
||||
/// <summary>A fake whose Execute PCCC envelope itself is refused with CIP general status <paramref name="general"/>.</summary>
|
||||
private static FakeEipServer CipEnvelopeServer(byte general)
|
||||
{
|
||||
var server = new FakeEipServer { ZeroReplyContext = true };
|
||||
server.Handler = req => FakeEipServer.Command(req) == Ab.Transport.Eip.EncapCommand.SendRRData
|
||||
? FakeEipServer.RRReply(req, [0xCB, 0x00, general, 0x00])
|
||||
: null;
|
||||
return server;
|
||||
}
|
||||
|
||||
/// <summary>A PCCC fake that answers every request with one fixed STS.</summary>
|
||||
private static FakeEipServer StsServer(byte sts)
|
||||
{
|
||||
var server = new FakeEipServer { ZeroReplyContext = true };
|
||||
server.Handler = req =>
|
||||
{
|
||||
if (FakeEipServer.Command(req) != Ab.Transport.Eip.EncapCommand.SendRRData) return null;
|
||||
var cip = FakeEipServer.RequestCip(req);
|
||||
byte[] head = [0xCB, 0x00, 0x00, 0x00, .. FixedId, 0x4F];
|
||||
return FakeEipServer.RRReply(req, [.. head, sts, cip[15], cip[16]]);
|
||||
};
|
||||
return server;
|
||||
}
|
||||
|
||||
#pragma warning disable OTOPCUA0001 // the driver is called directly to pin its OWN classification (no resilience wrap)
|
||||
private static async Task<AbLegacyDriver> DriverAsync(FakeEipServer server)
|
||||
{
|
||||
var host = $"ab://127.0.0.1:{server.Port}/1,0";
|
||||
var driver = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(host, AbLegacyPlcFamily.Slc500)],
|
||||
RawTags = AbLegacyRawTags.Entries([new AbLegacyTagDefinition("T", host, "N7:0", AbLegacyDataType.Int)]),
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-pccc-bridge", new NativeAbLegacyTagFactory(new DeviceSessionPool()));
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
return driver;
|
||||
}
|
||||
|
||||
/// <summary>STS 0x30 from a bridge: a comms fault, never evidence the device answered.</summary>
|
||||
[Fact]
|
||||
public async Task A_dead_node_behind_a_bridge_degrades_and_does_not_advance_LastSuccessfulRead()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var server = StsServer(0x30);
|
||||
var driver = await DriverAsync(server);
|
||||
var before = driver.GetHealth().LastSuccessfulRead;
|
||||
|
||||
var result = (await driver.ReadAsync(["T"], ct)).Single();
|
||||
|
||||
result.StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
var health = driver.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Degraded);
|
||||
health.LastSuccessfulRead.ShouldBe(before);
|
||||
await driver.ShutdownAsync(ct);
|
||||
server.HandlerErrors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Positive control: STS 0x60 is the live device refusing the tag — answered, so Healthy and advanced.</summary>
|
||||
[Fact]
|
||||
public async Task A_live_device_refusing_the_tag_stays_Healthy_and_advances_LastSuccessfulRead()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var server = StsServer(0x60);
|
||||
var driver = await DriverAsync(server);
|
||||
var before = driver.GetHealth().LastSuccessfulRead;
|
||||
|
||||
var result = (await driver.ReadAsync(["T"], ct)).Single();
|
||||
|
||||
result.StatusCode.ShouldBe(AbOpcUaStatusMap.BadNotWritable);
|
||||
var health = driver.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Healthy);
|
||||
health.LastSuccessfulRead.ShouldNotBeNull();
|
||||
health.LastSuccessfulRead.ShouldNotBe(before);
|
||||
await driver.ShutdownAsync(ct);
|
||||
server.HandlerErrors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review I2: a refusal that came OFF THE WIRE — the device (or the bridge's Execute PCCC object) answered with
|
||||
/// CIP 0x08 / 0x1C / 0x20 on the envelope, or PCCC STS 0x10 — is an answered refusal: per-tag BadNotSupported,
|
||||
/// Healthy, <c>LastSuccessfulRead</c> advanced. It must not land in the "refused before the wire" neutral class
|
||||
/// (which advances nothing). The local-refusal counterpart stays neutral (NativeAbLegacyRefusedWriteTests).
|
||||
/// </summary>
|
||||
/// <param name="general">The CIP general status of the envelope reply, or 0 for a PCCC STS 0x10 reply.</param>
|
||||
[Theory]
|
||||
[InlineData((byte)0x08)]
|
||||
[InlineData((byte)0x1C)]
|
||||
[InlineData((byte)0x20)]
|
||||
[InlineData((byte)0x00)]
|
||||
public async Task A_wire_Unsupported_reply_is_an_answered_refusal(byte general)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var server = general == 0 ? StsServer(0x10) : CipEnvelopeServer(general);
|
||||
var driver = await DriverAsync(server);
|
||||
var before = driver.GetHealth().LastSuccessfulRead;
|
||||
|
||||
var result = (await driver.ReadAsync(["T"], ct)).Single();
|
||||
|
||||
result.StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
||||
var health = driver.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Healthy);
|
||||
health.LastSuccessfulRead.ShouldNotBeNull();
|
||||
health.LastSuccessfulRead.ShouldNotBe(before);
|
||||
await driver.ShutdownAsync(ct);
|
||||
server.HandlerErrors.ShouldBeEmpty();
|
||||
}
|
||||
#pragma warning restore OTOPCUA0001
|
||||
}
|
||||
+394
-93
@@ -5,131 +5,432 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #610 — pins <see cref="DriverArrayWriteSupport"/>'s Modbus row against the REAL write path. For every
|
||||
/// region × element type, the server advertises an array node writable exactly when the driver accepts a
|
||||
/// whole-array write for it. A driver change that gains or loses an array encoder fails here until the table
|
||||
/// follows — so the server can never advertise a write this driver refuses (nor hide one it accepts).
|
||||
/// Gitea #610 / #741 — pins <see cref="DriverArrayWriteSupport"/>'s Modbus row against the REAL write path. For
|
||||
/// every region × element type × node DataType, and for every shape edge (the FC16 / FC15 caps, the declared
|
||||
/// length, the type-dependent guards, the address keys), the server advertises an array node writable exactly when
|
||||
/// <see cref="ModbusDriver.WriteAsync"/> encodes the whole array onto the wire. A driver change that gains or loses
|
||||
/// an array encoder fails here until the table follows — so the server can never advertise a write this driver
|
||||
/// refuses (nor hide one it accepts).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// "Accepted" means Good status AND a write PDU (FC15 / FC16 / FC23) the device took. The transport is a
|
||||
/// spec-conformant device: it holds the whole 0–65535 address space and refuses what every conformant device
|
||||
/// refuses — a quantity over a protocol ceiling (FC15 1968 coils, FC16 123 and FC03 125 registers) or a span past
|
||||
/// address 65535. The driver sends one FC15 / FC16 (and, for BitInRegister[], one FC03) without chunking, so those
|
||||
/// ceilings are the device's refusal; a configured <c>MaxRegistersPerWrite</c> below 123 is the driver's own. FC23
|
||||
/// is opt-in and never changes the answer (pinned below).
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusArrayWriteSupportParityTests
|
||||
{
|
||||
private static IEnumerable<(ModbusRegion Region, ModbusDataType DataType)> Shapes() =>
|
||||
from region in Enum.GetValues<ModbusRegion>()
|
||||
from dt in Enum.GetValues<ModbusDataType>()
|
||||
select (region, dt);
|
||||
private const string Ref = "cell/modbus/arr";
|
||||
|
||||
public static TheoryData<ModbusRegion, ModbusDataType> AllShapes()
|
||||
/// <summary>The CLR array the node manager hands the driver for a node of this DataType (it converts a numeric
|
||||
/// client array to the node's element type first).</summary>
|
||||
private static Array ClrArray(string nodeType, int n) => nodeType switch
|
||||
{
|
||||
var data = new TheoryData<ModbusRegion, ModbusDataType>();
|
||||
foreach (var (region, dt) in Shapes()) data.Add(region, dt);
|
||||
return data;
|
||||
}
|
||||
"Boolean" => Enumerable.Repeat(true, n).ToArray(),
|
||||
"Int16" => Enumerable.Repeat((short)1, n).ToArray(),
|
||||
"UInt16" => Enumerable.Repeat((ushort)1, n).ToArray(),
|
||||
"Int32" => Enumerable.Repeat(1, n).ToArray(),
|
||||
"Int64" => Enumerable.Repeat(1L, n).ToArray(),
|
||||
"Float" => Enumerable.Repeat(1f, n).ToArray(),
|
||||
"Double" => Enumerable.Repeat(1d, n).ToArray(),
|
||||
// Non-numeric text: a String node over a numeric / bit element converts only when the text happens to parse,
|
||||
// which is not a write the server can promise — the table keeps it read-only.
|
||||
"String" => Enumerable.Repeat("a", n).ToArray(),
|
||||
"DateTime" => Enumerable.Repeat(DateTime.UtcNow, n).ToArray(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(nodeType)),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Shapes where the driver has moved ahead of the server-side table (review 07 P6 / U3, Modbus lane) and
|
||||
/// <see cref="DriverArrayWriteSupport"/> — owned outside the Modbus lane — has not followed yet
|
||||
/// (follow-up F1): the tag factory now REFUSES a Coils tag with a non-Bool type (so the driver serves no
|
||||
/// such tag), and the driver now encodes String[] and BitInRegister[] holding-register writes.
|
||||
/// <see cref="Pending_F1_divergences_still_exist"/> fails the moment the table follows, so this list
|
||||
/// is removed with it rather than left to rot.
|
||||
/// </summary>
|
||||
private static bool PendingF1(ModbusRegion region, ModbusDataType dataType) =>
|
||||
(region == ModbusRegion.Coils && dataType != ModbusDataType.Bool)
|
||||
|| (region == ModbusRegion.HoldingRegisters && dataType is ModbusDataType.String or ModbusDataType.BitInRegister);
|
||||
private static readonly string[] NodeTypes = ["Boolean", "Int16", "UInt16", "Int32", "Int64", "Float", "Double", "String", "DateTime"];
|
||||
|
||||
|
||||
public static TheoryData<ModbusRegion, ModbusDataType> PendingShapes()
|
||||
/// <summary>Drives the real driver: true iff the write is Good AND a write PDU reached the device.</summary>
|
||||
private static async Task<(bool Accepted, uint Status)> DriverEncodesAsync(
|
||||
string tagConfig, object value, ushort maxRegistersPerWrite = 123, bool fc23 = false, ushort maxRegistersPerRead = 125)
|
||||
{
|
||||
var data = new TheoryData<ModbusRegion, ModbusDataType>();
|
||||
foreach (var (region, dt) in Shapes().Where(s => PendingF1(s.Region, s.DataType))) data.Add(region, dt);
|
||||
return data;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(PendingShapes))]
|
||||
public void Pending_F1_divergences_still_exist(ModbusRegion region, ModbusDataType dataType)
|
||||
{
|
||||
var json = $$"""{"region":"{{region}}","address":10,"dataType":"{{dataType}}","stringLength":4,"isArray":true,"arrayLength":2}""";
|
||||
var driverAccepts = region == ModbusRegion.HoldingRegisters; // String[] / BitInRegister[] encode; mistyped Coils are refused
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", json).ShouldNotBe(driverAccepts,
|
||||
"F1 landed — DriverArrayWriteSupport now agrees with the driver for this shape; delete it from PendingF1");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AllShapes))]
|
||||
public async Task Table_agrees_with_the_driver_write_path(ModbusRegion region, ModbusDataType dataType)
|
||||
{
|
||||
if (PendingF1(region, dataType)) return; // covered by Pending_F1_divergences_still_exist
|
||||
var json = $$"""{"region":"{{region}}","address":10,"dataType":"{{dataType}}","stringLength":4,"isArray":true,"arrayLength":2}""";
|
||||
var fake = new ModbusDriverTests.FakeTransport();
|
||||
// The operator configures maxRegistersPerRead because the DEVICE reads no more than that per FC03, so the
|
||||
// modelled device refuses a longer read (never above FC03's protocol ceiling of 125).
|
||||
var device = new ConformantDevice { MaxReadRegisters = Math.Min(maxRegistersPerRead == 0 ? 125 : (int)maxRegistersPerRead, 125) };
|
||||
var drv = new ModbusDriver(
|
||||
new ModbusDriverOptions { Host = "fake", RawTags = [new RawTagEntry("cell/modbus/arr", json, false)] },
|
||||
"modbus-array-parity", _ => fake);
|
||||
new ModbusDriverOptions
|
||||
{
|
||||
Host = "fake", RawTags = [new RawTagEntry(Ref, tagConfig, false)],
|
||||
MaxRegistersPerWrite = maxRegistersPerWrite, UseFC23ForMultiRegisterWrites = fc23,
|
||||
MaxRegistersPerRead = maxRegistersPerRead,
|
||||
},
|
||||
"modbus-array-parity", _ => device);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// A two-element value every element type can convert (Convert.ToBoolean / ToInt16 / ToSingle … of 1).
|
||||
var result = await drv.WriteAsync([new WriteRequest("cell/modbus/arr", new object[] { 1, 1 })], CancellationToken.None);
|
||||
var driverAccepts = result.Single().StatusCode == 0u;
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", json).ShouldBe(
|
||||
driverAccepts, $"region={region} dataType={dataType}: driver status 0x{result.Single().StatusCode:X8}");
|
||||
var result = await drv.WriteAsync([new WriteRequest(Ref, value)], CancellationToken.None);
|
||||
var status = result.Single().StatusCode;
|
||||
return (status == 0u && device.WritePdus > 0, status);
|
||||
}
|
||||
|
||||
/// <summary>Positive control: the theory above is not vacuously "everything refused".</summary>
|
||||
public static TheoryData<ModbusRegion, ModbusDataType, string> TypeMatrix()
|
||||
{
|
||||
var data = new TheoryData<ModbusRegion, ModbusDataType, string>();
|
||||
foreach (var region in Enum.GetValues<ModbusRegion>())
|
||||
foreach (var dt in Enum.GetValues<ModbusDataType>())
|
||||
foreach (var node in NodeTypes)
|
||||
data.Add(region, dt, node);
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>Every region × element type × node DataType: the table answers exactly what the write path does —
|
||||
/// including String[] and BitInRegister[] holding-register arrays (#741 U3) and Coils authored with a non-Bool type,
|
||||
/// which the resolver COERCES to Bool (never refuses).</summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(TypeMatrix))]
|
||||
public async Task Table_agrees_with_the_driver_write_path(ModbusRegion region, ModbusDataType dataType, string nodeType)
|
||||
{
|
||||
var json = $$"""{"region":"{{region}}","address":10,"dataType":"{{dataType}}","stringLength":4,"bitIndex":3,"isArray":true,"arrayLength":2}""";
|
||||
|
||||
var (accepted, status) = await DriverEncodesAsync(json, ClrArray(nodeType, 2));
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, nodeType, json).ShouldBe(
|
||||
accepted, $"region={region} dataType={dataType} node={nodeType}: driver status 0x{status:X8}");
|
||||
}
|
||||
|
||||
/// <summary>Positive control: the matrix above is not vacuously one answer, and it contains the shapes #741 added.</summary>
|
||||
[Fact]
|
||||
public void The_matrix_contains_both_answers()
|
||||
{
|
||||
var answers = Shapes()
|
||||
.Select(s => DriverArrayWriteSupport.Supports(
|
||||
var answers = Enum.GetValues<ModbusRegion>()
|
||||
.SelectMany(r => Enum.GetValues<ModbusDataType>().Select(d => DriverArrayWriteSupport.Supports(
|
||||
DriverTypeNames.Modbus, "Int16",
|
||||
$$"""{"region":"{{s.Region}}","address":10,"dataType":"{{s.DataType}}","isArray":true,"arrayLength":2}"""))
|
||||
$$"""{"region":"{{r}}","address":10,"dataType":"{{d}}","stringLength":4,"isArray":true,"arrayLength":2}""")))
|
||||
.ToHashSet();
|
||||
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).
|
||||
/// The shape edges, both directions, against the real write path: the FC16 cap for numeric, String and
|
||||
/// BitInRegister arrays (default 123, or a configured <c>MaxRegistersPerWrite</c>), the FC15 ceiling, a
|
||||
/// missing / zero <c>arrayLength</c> (a scalar to the factory, whose scalar encoder refuses an array value),
|
||||
/// the String length and bit-index guards, the BitInRegister span end, and the strict key reads.
|
||||
/// </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)
|
||||
// Numeric FC16 cap.
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int32","isArray":true,"arrayLength":61}""", 61, 0)] // 122 regs
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int32","isArray":true,"arrayLength":62}""", 62, 0)] // 124 regs
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":123}""", 123, 0)] // exactly 123
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":124}""", 124, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Float64","isArray":true,"arrayLength":30}""", 30, 0)] // 120 regs
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Float64","isArray":true,"arrayLength":31}""", 31, 0)] // 124 regs
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":10}""", 10, 10)] // exactly a configured cap
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":11}""", 11, 10)] // one over it
|
||||
// String FC16 cap: (stringLength+1)/2 registers per element.
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":4,"isArray":true,"arrayLength":61}""", 61, 0)] // 122 regs
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":4,"isArray":true,"arrayLength":62}""", 62, 0)] // 124 regs
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":5,"isArray":true,"arrayLength":41}""", 41, 0)] // 3 regs each: 123
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":5,"isArray":true,"arrayLength":42}""", 42, 0)] // 126
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":1,"isArray":true,"arrayLength":10}""", 10, 10)] // 1 reg each, at a configured cap
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":1,"isArray":true,"arrayLength":11}""", 11, 10)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":0,"isArray":true,"arrayLength":2}""", 2, 0)] // factory refuses
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","isArray":true,"arrayLength":2}""", 2, 0)] // no length ⇒ refused
|
||||
// BitInRegister FC16 cap: one register per element, plus the bit-index guard and the span end.
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","bitIndex":15,"isArray":true,"arrayLength":123}""", 123, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","bitIndex":15,"isArray":true,"arrayLength":124}""", 124, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","isArray":true,"arrayLength":10}""", 10, 10)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","isArray":true,"arrayLength":11}""", 11, 10)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","bitIndex":16,"isArray":true,"arrayLength":2}""", 2, 0)] // factory refuses
|
||||
[InlineData("""{"region":"HoldingRegisters","address":65530,"dataType":"BitInRegister","isArray":true,"arrayLength":6}""", 6, 0)] // ends at 65535
|
||||
[InlineData("""{"region":"HoldingRegisters","address":65530,"dataType":"BitInRegister","isArray":true,"arrayLength":7}""", 7, 0)] // past the end
|
||||
// FC15 ceiling (one request, no chunking; a conformant device refuses more than 1968 coils).
|
||||
[InlineData("""{"region":"Coils","address":0,"isArray":true,"arrayLength":1968}""", 1968, 0)]
|
||||
[InlineData("""{"region":"Coils","address":0,"isArray":true,"arrayLength":1969}""", 1969, 0)]
|
||||
[InlineData("""{"region":"Coils","address":0,"dataType":"Float64","isArray":true,"arrayLength":1968}""", 1968, 0)] // coerced to Bool
|
||||
[InlineData("""{"region":"Coils","address":0,"dataType":"Float64","isArray":true,"arrayLength":1969}""", 1969, 0)]
|
||||
// Declared length.
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":0}""", 2, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true}""", 2, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":1}""", 1, 0)]
|
||||
// Strict key reads: a present-but-invalid enum or an out-of-range address refuses the tag.
|
||||
[InlineData("""{"region":"Coils","address":0,"dataType":"Int17","isArray":true,"arrayLength":2}""", 2, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","byteOrder":"Sideways","isArray":true,"arrayLength":2}""", 2, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":4,"stringByteOrder":"Middle","isArray":true,"arrayLength":2}""", 2, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","byteOrder":"WordSwap","isArray":true,"arrayLength":2}""", 2, 0)] // valid order: positive control
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":4,"stringByteOrder":"LowByteFirst","isArray":true,"arrayLength":2}""", 2, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":65536,"dataType":"Int16","isArray":true,"arrayLength":2}""", 2, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":-1,"dataType":"Int16","isArray":true,"arrayLength":2}""", 2, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":1.5,"dataType":"Int16","isArray":true,"arrayLength":2}""", 2, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":"10","dataType":"Int16","isArray":true,"arrayLength":2}""", 2, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Bool","isArray":true,"arrayLength":2}""", 2, 0)] // register region never carries Bool
|
||||
[InlineData("""{"region":0,"address":10,"dataType":"Int16","isArray":true,"arrayLength":2}""", 2, 0)] // non-string region ⇒ absent ⇒ HoldingRegisters
|
||||
// Keys win over an addressString beside them (the driver never reads the string then).
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"addressString":"30011","dataType":"Int16","isArray":true,"arrayLength":2}""", 2, 0)]
|
||||
public async Task Table_agrees_with_the_driver_on_each_shape_edge(string tagConfig, int elements, ushort maxRegistersPerWrite)
|
||||
{
|
||||
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 cap = maxRegistersPerWrite == 0 ? (ushort)123 : maxRegistersPerWrite;
|
||||
|
||||
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;
|
||||
var (accepted, status) = await DriverEncodesAsync(tagConfig, ClrArray("Int16", elements), cap);
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", json, driverConfig).ShouldBe(
|
||||
driverAccepts, $"{dataType} {lengthProp} cap={maxRegistersPerWrite}: driver status 0x{result.Single().StatusCode:X8}");
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tagConfig, driverConfig).ShouldBe(
|
||||
accepted, $"{tagConfig} cap={cap}: driver status 0x{status:X8}");
|
||||
}
|
||||
|
||||
/// <summary>Positive control for the theory above: both answers occur, so it is not vacuously all-refused.</summary>
|
||||
/// <summary>The table restates the Modbus enums' member names (Core.Abstractions cannot reference them); every
|
||||
/// byte order and string byte order the driver accepts must be one it accepts too, so a new member fails here.</summary>
|
||||
[Fact]
|
||||
public async Task Every_byte_order_member_agrees_with_the_driver()
|
||||
{
|
||||
foreach (var order in Enum.GetValues<ModbusByteOrder>())
|
||||
foreach (var stringOrder in Enum.GetValues<ModbusStringByteOrder>())
|
||||
{
|
||||
var json = $$"""{"region":"HoldingRegisters","address":10,"dataType":"Int32","byteOrder":"{{order}}","stringByteOrder":"{{stringOrder}}","isArray":true,"arrayLength":2}""";
|
||||
var (accepted, _) = await DriverEncodesAsync(json, ClrArray("Int16", 2));
|
||||
accepted.ShouldBeTrue(json);
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int32", json).ShouldBeTrue(json);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The protocol ceilings and the configured caps together, against the real write path:
|
||||
/// <list type="bullet">
|
||||
/// <item>a <c>maxRegistersPerWrite</c> above FC16's 123 never lifts it - the driver sends the over-ceiling
|
||||
/// FC16 and a conformant device refuses it (past 127 the byte-count field even wraps);</item>
|
||||
/// <item>a BitInRegister[] write reads its span first in ONE unchunked FC03, so it is also bounded by
|
||||
/// <c>maxRegistersPerRead</c> (and FC03's 125); numeric arrays never read on write;</item>
|
||||
/// <item>every span ends at or before address 65535 (exception 02 past it).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
[Theory]
|
||||
// Write cap above the FC16 ceiling.
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":123}""", 123, 200, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":124}""", 124, 200, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":150}""", 150, 200, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":4,"isArray":true,"arrayLength":62}""", 62, 200, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","isArray":true,"arrayLength":123}""", 123, 200, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","isArray":true,"arrayLength":124}""", 124, 200, 200)]
|
||||
// BitInRegister[] RMW read bounded by maxRegistersPerRead.
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","isArray":true,"arrayLength":64}""", 64, 0, 64)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","isArray":true,"arrayLength":65}""", 65, 0, 64)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","isArray":true,"arrayLength":100}""", 100, 0, 64)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":100}""", 100, 0, 64)] // no read on write
|
||||
// Span end at 65535.
|
||||
[InlineData("""{"region":"HoldingRegisters","address":65530,"dataType":"Int16","isArray":true,"arrayLength":6}""", 6, 0, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":65530,"dataType":"Int16","isArray":true,"arrayLength":7}""", 7, 0, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":65532,"dataType":"Int32","isArray":true,"arrayLength":2}""", 2, 0, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":65533,"dataType":"Int32","isArray":true,"arrayLength":2}""", 2, 0, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":65534,"dataType":"String","stringLength":4,"isArray":true,"arrayLength":1}""", 1, 0, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":65535,"dataType":"String","stringLength":4,"isArray":true,"arrayLength":1}""", 1, 0, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":65530,"dataType":"BitInRegister","isArray":true,"arrayLength":6}""", 6, 0, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":65530,"dataType":"BitInRegister","isArray":true,"arrayLength":7}""", 7, 0, 0)]
|
||||
[InlineData("""{"region":"Coils","address":65000,"isArray":true,"arrayLength":536}""", 536, 0, 0)]
|
||||
[InlineData("""{"region":"Coils","address":65000,"isArray":true,"arrayLength":537}""", 537, 0, 0)]
|
||||
public async Task Table_agrees_with_the_driver_on_protocol_ceilings_and_spans(
|
||||
string tagConfig, int elements, ushort maxRegistersPerWrite, ushort maxRegistersPerRead)
|
||||
{
|
||||
var configs = new List<string>();
|
||||
if (maxRegistersPerWrite != 0) configs.Add($$"""{"maxRegistersPerWrite":{{maxRegistersPerWrite}}}""");
|
||||
if (maxRegistersPerRead != 0) configs.Add($$"""{"maxRegistersPerRead":{{maxRegistersPerRead}}}""");
|
||||
|
||||
var (accepted, status) = await DriverEncodesAsync(tagConfig, ClrArray("Int16", elements),
|
||||
maxRegistersPerWrite == 0 ? (ushort)123 : maxRegistersPerWrite,
|
||||
maxRegistersPerRead: maxRegistersPerRead == 0 ? (ushort)125 : maxRegistersPerRead);
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tagConfig, configs.ToArray()).ShouldBe(
|
||||
accepted, $"{tagConfig} write={maxRegistersPerWrite} read={maxRegistersPerRead}: driver status 0x{status:X8}");
|
||||
}
|
||||
|
||||
/// <summary>Positive control for the edges: each cap boundary has a writable side and a refused side in the table.</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();
|
||||
static bool S(string json) => DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", json);
|
||||
S("""{"region":"HoldingRegisters","address":10,"dataType":"Int32","isArray":true,"arrayLength":61}""").ShouldBeTrue();
|
||||
S("""{"region":"HoldingRegisters","address":10,"dataType":"Int32","isArray":true,"arrayLength":62}""").ShouldBeFalse();
|
||||
S("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":5,"isArray":true,"arrayLength":41}""").ShouldBeTrue();
|
||||
S("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":5,"isArray":true,"arrayLength":42}""").ShouldBeFalse();
|
||||
S("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","isArray":true,"arrayLength":123}""").ShouldBeTrue();
|
||||
S("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","isArray":true,"arrayLength":124}""").ShouldBeFalse();
|
||||
S("""{"region":"Coils","address":0,"isArray":true,"arrayLength":1968}""").ShouldBeTrue();
|
||||
S("""{"region":"Coils","address":0,"isArray":true,"arrayLength":1969}""").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FC23 is opt-in and never changes writability: with <c>UseFC23ForMultiRegisterWrites</c> on, the driver
|
||||
/// writes a multi-register array via FC23 up to its 121-register ceiling and falls back to FC16 above it, so
|
||||
/// the accept boundary is still the FC16 cap the table uses.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("Int16", 121)]
|
||||
[InlineData("Int16", 123)]
|
||||
[InlineData("Int16", 124)]
|
||||
[InlineData("String", 61)]
|
||||
[InlineData("String", 62)]
|
||||
public async Task FC23_does_not_change_the_answer(string dataType, int elements)
|
||||
{
|
||||
var json = $$"""{"region":"HoldingRegisters","address":10,"dataType":"{{dataType}}","stringLength":4,"isArray":true,"arrayLength":{{elements}}}""";
|
||||
|
||||
var (fc16, _) = await DriverEncodesAsync(json, ClrArray("Int16", elements), fc23: false);
|
||||
var (fc23, _) = await DriverEncodesAsync(json, ClrArray("Int16", elements), fc23: true);
|
||||
|
||||
fc23.ShouldBe(fc16);
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", json).ShouldBe(fc23);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A string-only tag (<c>addressString</c>, no <c>region</c> + <c>address</c> keys) is resolved by the DRIVER
|
||||
/// with its own family. Core.Abstractions cannot parse that grammar (it references no driver assembly), so the
|
||||
/// table cannot prove such an array writable and keeps it read-only — even though the Generic-family driver does
|
||||
/// write this one. That is the conservative direction (a hidden write, never an advertised refusal); a tag that
|
||||
/// needs to be array-writable is authored with the keys (the editor's "Resolve" pins them).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("""{"addressString":"40011:S::2","isArray":true,"arrayLength":2}""")]
|
||||
[InlineData("""{"addressString":"40011:F:2"}""")]
|
||||
[InlineData("""{"region":"HoldingRegisters","addressString":"40011:S::2","isArray":true,"arrayLength":2}""")] // region alone is not the keys
|
||||
public async Task A_string_only_array_tag_stays_read_only(string tagConfig)
|
||||
{
|
||||
var (accepted, status) = await DriverEncodesAsync(tagConfig, ClrArray("Int16", 2));
|
||||
|
||||
accepted.ShouldBeTrue($"the Generic driver resolves and writes it (status 0x{status:X8}) — this pins the conservative gap, not a refusal");
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tagConfig).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The ScadaBridge wire shape: a CLR <c>int[]</c> / <c>List<int></c> to an Int16 holding-register array on
|
||||
/// an Int16 node is advertised writable and CONVERTS element by element onto the wire (it is not exact-matched).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_ScadaBridge_int_list_to_an_Int16_array_encodes()
|
||||
{
|
||||
const string json = """{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":3}""";
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", json).ShouldBeTrue();
|
||||
|
||||
foreach (object value in new object[] { new[] { 1, -2, 300 }, new List<int> { 1, -2, 300 } })
|
||||
{
|
||||
var device = new ConformantDevice();
|
||||
var drv = new ModbusDriver(
|
||||
new ModbusDriverOptions { Host = "fake", RawTags = [new RawTagEntry(Ref, json, false)] }, "modbus-sb", _ => device);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
(await drv.WriteAsync([new WriteRequest(Ref, value)], CancellationToken.None)).Single().StatusCode.ShouldBe(0u);
|
||||
device.Holding[10..13].ShouldBe(new ushort[] { 1, 0xFFFE, 300 });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The two #741 U3 shapes encode end to end with their own node types: String[] on a String node and
|
||||
/// BitInRegister[] on a Boolean node, each preserving what the wire must carry.</summary>
|
||||
[Fact]
|
||||
public async Task String_and_BitInRegister_arrays_encode_on_their_own_node_types()
|
||||
{
|
||||
const string strings = """{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":3,"isArray":true,"arrayLength":2}""";
|
||||
const string bits = """{"region":"HoldingRegisters","address":20,"dataType":"BitInRegister","bitIndex":2,"isArray":true,"arrayLength":2}""";
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "String", strings).ShouldBeTrue();
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", bits).ShouldBeTrue();
|
||||
|
||||
var device = new ConformantDevice();
|
||||
device.Holding[20] = 0x0001;
|
||||
device.Holding[21] = 0x0005;
|
||||
var drv = new ModbusDriver(
|
||||
new ModbusDriverOptions { Host = "fake", RawTags = [new RawTagEntry("s", strings, false), new RawTagEntry("b", bits, false)] },
|
||||
"modbus-u3", _ => device);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.WriteAsync(
|
||||
[new WriteRequest("s", new[] { "ABC", "de" }), new WriteRequest("b", new[] { true, false })], CancellationToken.None);
|
||||
|
||||
r.Select(x => x.StatusCode).ShouldBe(new[] { 0u, 0u });
|
||||
device.Holding[10..14].ShouldBe(new ushort[] { 0x4142, 0x4300, 0x6465, 0x0000 }); // 2 regs per 3-char element, nul-padded
|
||||
device.Holding[20..22].ShouldBe(new ushort[] { 0x0005, 0x0001 }); // bit 2 set / cleared, other bits kept
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A spec-conformant Modbus device over the whole 0-65535 address space. It enforces the protocol quantity
|
||||
/// ceilings (FC01/02 <= 2000, FC03/04 <= 125, FC15 <= 1968, FC16 <= 123, FC23 write <= 121 / read <= 125) and the
|
||||
/// FC15 / FC16 / FC23 byte-count field AND the exact frame length it implies (a strict device refuses a trailing
|
||||
/// byte) with exception 03 (Illegal Data Value), and a span past address 65535 with
|
||||
/// exception 02 (Illegal Data Address) - what a real device answers. Everything else it answers.
|
||||
/// </summary>
|
||||
private sealed class ConformantDevice : IModbusTransport
|
||||
{
|
||||
public readonly ushort[] Holding = new ushort[65536];
|
||||
public readonly bool[] Coils = new bool[65536];
|
||||
public int WritePdus;
|
||||
|
||||
/// <summary>The device's FC03/FC04 quantity limit: the protocol's 125, or a smaller device-specific one.</summary>
|
||||
public int MaxReadRegisters { get; init; } = 125;
|
||||
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
private static Task<byte[]> Refuse(byte fc, byte code) =>
|
||||
Task.FromException<byte[]>(new ModbusException(fc, code, $"fc={fc:X2} exception {code:X2}"));
|
||||
|
||||
// Exception 03 for a quantity outside 1..max, 02 for a span past the last address, else null (accepted).
|
||||
private static byte? Check(int addr, int qty, int max) =>
|
||||
qty < 1 || qty > max ? (byte)0x03 : addr + qty > 65536 ? (byte)0x02 : null;
|
||||
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
var fc = pdu[0];
|
||||
var addr = (pdu[1] << 8) | pdu[2];
|
||||
var qty = (pdu[3] << 8) | pdu[4];
|
||||
switch (fc)
|
||||
{
|
||||
case 0x01:
|
||||
case 0x02:
|
||||
{
|
||||
if (Check(addr, qty, 2000) is { } e) return Refuse(fc, e);
|
||||
var resp = new byte[2 + (qty + 7) / 8];
|
||||
resp[0] = fc; resp[1] = (byte)((qty + 7) / 8);
|
||||
for (var i = 0; i < qty; i++) if (Coils[addr + i]) resp[2 + i / 8] |= (byte)(1 << (i % 8));
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
case 0x03:
|
||||
case 0x04:
|
||||
if (Check(addr, qty, MaxReadRegisters) is { } re) return Refuse(fc, re);
|
||||
return Task.FromResult(ReadRegs(fc, addr, qty));
|
||||
case 0x05:
|
||||
WritePdus++;
|
||||
Coils[addr] = pdu[3] == 0xFF;
|
||||
return Task.FromResult(pdu);
|
||||
case 0x06:
|
||||
WritePdus++;
|
||||
Holding[addr] = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
return Task.FromResult(pdu);
|
||||
case 0x0F:
|
||||
if (Check(addr, qty, 1968) is { } ce) return Refuse(fc, ce);
|
||||
if (pdu[5] != (qty + 7) / 8 || pdu.Length != 6 + pdu[5]) return Refuse(fc, 0x03); // strict frame length
|
||||
WritePdus++;
|
||||
for (var i = 0; i < qty; i++) Coils[addr + i] = ((pdu[6 + i / 8] >> (i % 8)) & 1) == 1;
|
||||
return Task.FromResult(new byte[] { 0x0F, pdu[1], pdu[2], pdu[3], pdu[4] });
|
||||
case 0x10:
|
||||
if (Check(addr, qty, 123) is { } we) return Refuse(fc, we);
|
||||
if (pdu[5] != qty * 2 || pdu.Length != 6 + pdu[5]) return Refuse(fc, 0x03); // strict frame length
|
||||
WritePdus++;
|
||||
for (var i = 0; i < qty; i++) Holding[addr + i] = (ushort)((pdu[6 + i * 2] << 8) | pdu[7 + i * 2]);
|
||||
return Task.FromResult(new byte[] { 0x10, pdu[1], pdu[2], pdu[3], pdu[4] });
|
||||
case 0x17:
|
||||
{
|
||||
var writeAddr = (pdu[5] << 8) | pdu[6];
|
||||
var writeQty = (pdu[7] << 8) | pdu[8];
|
||||
if (Check(addr, qty, 125) is { } r23) return Refuse(fc, r23);
|
||||
if (Check(writeAddr, writeQty, 121) is { } w23) return Refuse(fc, w23);
|
||||
if (pdu[9] != writeQty * 2 || pdu.Length != 10 + pdu[9]) return Refuse(fc, 0x03); // strict frame length
|
||||
WritePdus++;
|
||||
for (var i = 0; i < writeQty; i++) Holding[writeAddr + i] = (ushort)((pdu[10 + i * 2] << 8) | pdu[11 + i * 2]);
|
||||
return Task.FromResult(ReadRegs(0x17, addr, qty));
|
||||
}
|
||||
default:
|
||||
return Refuse(fc, 0x01);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] ReadRegs(byte fc, int addr, int qty)
|
||||
{
|
||||
var resp = new byte[2 + qty * 2];
|
||||
resp[0] = fc; resp[1] = (byte)(qty * 2);
|
||||
for (var i = 0; i < qty; i++)
|
||||
{
|
||||
resp[2 + i * 2] = (byte)(Holding[addr + i] >> 8);
|
||||
resp[3 + i * 2] = (byte)(Holding[addr + i] & 0xFF);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// R07:L5 (Modbus half): the RTU ADU is now written into a pooled buffer and the response is deframed in one pooled
|
||||
/// buffer. The wire bytes and the decoded PDUs must be exactly what the allocating code produced, and a reused pooled
|
||||
/// buffer must never leak a previous frame's bytes into the next result.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusPooledFramingTests
|
||||
{
|
||||
/// <summary>The pooled ADU writer matches the reference construction (unit + PDU, CRC appended low byte first).</summary>
|
||||
/// <param name="hex">The PDU as hex.</param>
|
||||
[Theory]
|
||||
[InlineData("0300000001")]
|
||||
[InlineData("10000A0002040001000200")]
|
||||
[InlineData("06")]
|
||||
public void WriteAdu_matches_the_reference_ADU(string hex)
|
||||
{
|
||||
var pdu = Convert.FromHexString(hex);
|
||||
var reference = ModbusCrc.Append([0x11, .. pdu]);
|
||||
|
||||
var pooled = new byte[ModbusRtuFraming.AduLength(pdu.Length) + 7]; // oversized, like a rented array
|
||||
ModbusRtuFraming.WriteAdu(0x11, pdu, pooled.AsSpan(0, ModbusRtuFraming.AduLength(pdu.Length)));
|
||||
|
||||
pooled.AsSpan(0, reference.Length).ToArray().ShouldBe(reference);
|
||||
ModbusRtuFraming.BuildAdu(0x11, pdu).ShouldBe(reference);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A long read reply followed by a short echo and an exception frame, each deframed through the pooled buffer:
|
||||
/// every result is exactly its own PDU (no stale tail from the previous, longer frame).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Consecutive_deframes_through_the_pooled_buffer_do_not_leak_bytes()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
byte[] longRead = [0x03, 200, .. Enumerable.Range(0, 200).Select(i => (byte)(i + 1))];
|
||||
byte[] echo = [0x06, 0x00, 0x01, 0x00, 0x2A];
|
||||
|
||||
(await ModbusRtuFraming.ReadResponsePduAsync(new MemoryStream(ModbusRtuFraming.BuildAdu(1, longRead)), 1, ct))
|
||||
.ShouldBe(longRead);
|
||||
(await ModbusRtuFraming.ReadResponsePduAsync(new MemoryStream(ModbusRtuFraming.BuildAdu(1, echo)), 1, ct))
|
||||
.ShouldBe(echo);
|
||||
var ex = await Should.ThrowAsync<ModbusException>(() =>
|
||||
ModbusRtuFraming.ReadResponsePduAsync(new MemoryStream(ModbusRtuFraming.BuildAdu(1, [0x83, 0x02])), 1, ct));
|
||||
ex.ExceptionCode.ShouldBe((byte)0x02);
|
||||
(await ModbusRtuFraming.ReadResponsePduAsync(new MemoryStream(ModbusRtuFraming.BuildAdu(1, [0x03, 2, 9, 8])), 1, ct))
|
||||
.ShouldBe(new byte[] { 0x03, 2, 9, 8 });
|
||||
}
|
||||
|
||||
/// <summary>A byte-count-255 read (the largest RTU reply) fits the pooled frame.</summary>
|
||||
[Fact]
|
||||
public async Task The_largest_read_reply_deframes()
|
||||
{
|
||||
byte[] max = [0x04, 255, .. new byte[255]];
|
||||
(await ModbusRtuFraming.ReadResponsePduAsync(new MemoryStream(ModbusRtuFraming.BuildAdu(1, max)), 1,
|
||||
TestContext.Current.CancellationToken)).ShouldBe(max);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// #741 (Integration-W3 fix round): every multi-write PDU the driver builds is EXACTLY its header plus the bytes its
|
||||
/// byte-count field declares — FC15 and FC16 are <c>6 + byteCount</c>, FC23 is <c>10 + writeByteCount</c>. The
|
||||
/// builders used to allocate one byte more than they filled, so every FC15 / FC16 / FC23 (and the BitInRegister[]
|
||||
/// read-modify-write's FC16) carried a trailing zero byte past the declared data — which a strict device or gateway
|
||||
/// that checks the frame length against the byte count refuses.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusWritePduFramingTests
|
||||
{
|
||||
private const string Ref = "arr";
|
||||
|
||||
/// <summary>Records every PDU; answers reads with zeros and multi-writes with the standard echo.</summary>
|
||||
private sealed class CapturingTransport : IModbusTransport
|
||||
{
|
||||
public readonly List<byte[]> Sent = [];
|
||||
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
Sent.Add(pdu);
|
||||
var qty = (pdu[3] << 8) | pdu[4];
|
||||
return Task.FromResult(pdu[0] switch
|
||||
{
|
||||
0x01 or 0x02 => [pdu[0], (byte)((qty + 7) / 8), .. new byte[(qty + 7) / 8]],
|
||||
0x03 or 0x04 or 0x17 => [pdu[0], (byte)(qty * 2), .. new byte[qty * 2]],
|
||||
0x0F or 0x10 => new[] { pdu[0], pdu[1], pdu[2], pdu[3], pdu[4] },
|
||||
_ => pdu,
|
||||
});
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private static async Task<byte[]> WriteAndCaptureAsync(string tagConfig, object value, byte functionCode, bool fc23 = false)
|
||||
{
|
||||
var transport = new CapturingTransport();
|
||||
var drv = new ModbusDriver(
|
||||
new ModbusDriverOptions
|
||||
{
|
||||
Host = "fake", RawTags = [new RawTagEntry(Ref, tagConfig, false)], UseFC23ForMultiRegisterWrites = fc23,
|
||||
},
|
||||
"modbus-framing", _ => transport);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
(await drv.WriteAsync([new WriteRequest(Ref, value)], CancellationToken.None)).Single().StatusCode.ShouldBe(0u);
|
||||
|
||||
return transport.Sent.Single(p => p[0] == functionCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(8)]
|
||||
[InlineData(9)]
|
||||
public async Task FC15_pdu_is_exactly_header_plus_byte_count(int coils)
|
||||
{
|
||||
var pdu = await WriteAndCaptureAsync(
|
||||
$$"""{"region":"Coils","address":0,"isArray":true,"arrayLength":{{coils}}}""",
|
||||
Enumerable.Repeat(true, coils).ToArray(), 0x0F);
|
||||
|
||||
pdu[5].ShouldBe((byte)((coils + 7) / 8));
|
||||
pdu.Length.ShouldBe(6 + pdu[5]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":0,"dataType":"Int16","isArray":true,"arrayLength":3}""", 6)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":0,"dataType":"Float64","isArray":true,"arrayLength":2}""", 16)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":0,"dataType":"Int32"}""", 4)] // scalar multi-register
|
||||
public async Task FC16_pdu_is_exactly_header_plus_byte_count(string tagConfig, int byteCount)
|
||||
{
|
||||
object value = tagConfig.Contains("isArray") ? new[] { 1, 2, 3 }.Take(tagConfig.Contains("Float64") ? 2 : 3).ToArray() : 7;
|
||||
|
||||
var pdu = await WriteAndCaptureAsync(tagConfig, value, 0x10);
|
||||
|
||||
pdu[5].ShouldBe((byte)byteCount);
|
||||
pdu.Length.ShouldBe(6 + pdu[5]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BitInRegister_array_RMW_FC16_pdu_is_exactly_header_plus_byte_count()
|
||||
{
|
||||
var pdu = await WriteAndCaptureAsync(
|
||||
"""{"region":"HoldingRegisters","address":0,"dataType":"BitInRegister","bitIndex":2,"isArray":true,"arrayLength":4}""",
|
||||
new[] { true, false, true, false }, 0x10);
|
||||
|
||||
pdu[5].ShouldBe((byte)8);
|
||||
pdu.Length.ShouldBe(6 + pdu[5]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FC23_pdu_is_exactly_header_plus_write_byte_count()
|
||||
{
|
||||
var pdu = await WriteAndCaptureAsync(
|
||||
"""{"region":"HoldingRegisters","address":0,"dataType":"Int16","isArray":true,"arrayLength":3}""",
|
||||
new[] { 1, 2, 3 }, 0x17, fc23: true);
|
||||
|
||||
pdu[9].ShouldBe((byte)6);
|
||||
pdu.Length.ShouldBe(10 + pdu[9]);
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// R04:P2 — an abandoned editor request (Monaco cancelled it, or the browser aborted the fetch) used to run
|
||||
/// its full compile to completion. The request's token now reaches the service, the Roslyn calls and the
|
||||
/// catalog, and the endpoints bind <c>HttpContext.RequestAborted</c>.
|
||||
/// </summary>
|
||||
public sealed class AnalysisCancellationTests
|
||||
{
|
||||
internal sealed class CapturingCatalog : IScriptTagCatalog
|
||||
{
|
||||
public readonly List<CancellationToken> Seen = new();
|
||||
|
||||
public Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct)
|
||||
{
|
||||
Seen.Add(ct);
|
||||
return Task.FromResult<IReadOnlyList<string>>(new[] { "Plant/Dev/Speed" });
|
||||
}
|
||||
|
||||
public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
|
||||
{
|
||||
Seen.Add(ct);
|
||||
return Task.FromResult<ScriptTagInfo?>(new ScriptTagInfo(path, "Tag", "Float", "DRV-1"));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct)
|
||||
{
|
||||
Seen.Add(ct);
|
||||
return Task.FromResult<IReadOnlyList<string>>(new[] { "Speed" });
|
||||
}
|
||||
}
|
||||
|
||||
private static CancellationToken Cancelled() => new(canceled: true);
|
||||
|
||||
[Fact]
|
||||
public async Task Diagnose_with_a_cancelled_token_throws_instead_of_returning_empty_markers()
|
||||
{
|
||||
var svc = new ScriptAnalysisService();
|
||||
// Positive control: the same request with a live token produces the error marker.
|
||||
(await svc.DiagnoseAsync(new DiagnoseRequest("return ctx.Nope();"), CancellationToken.None))
|
||||
.Markers.ShouldContain(m => m.Severity == 8);
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
svc.DiagnoseAsync(new DiagnoseRequest("return ctx.Nope();"), Cancelled()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Completions_hover_and_signature_help_honour_a_cancelled_token()
|
||||
{
|
||||
var svc = new ScriptAnalysisService(new CapturingCatalog());
|
||||
const string code = "return ctx.GetTag(\"\");";
|
||||
|
||||
// Positive controls.
|
||||
(await svc.CompleteAsync(new CompletionsRequest(code, 1, 20), CancellationToken.None)).Items.ShouldNotBeEmpty();
|
||||
(await svc.Hover(new HoverRequest("return ctx.GetTag(\"Plant/Dev/Speed\");", 1, 22), CancellationToken.None))
|
||||
.Markdown.ShouldNotBeNull();
|
||||
svc.SignatureHelp(new SignatureHelpRequest("ctx.GetTag(", 1, 12), CancellationToken.None).Label.ShouldNotBeNull();
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(svc.CompleteAsync(new CompletionsRequest(code, 1, 20), Cancelled()));
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
svc.Hover(new HoverRequest("return ctx.GetTag(\"Plant/Dev/Speed\");", 1, 22), Cancelled()));
|
||||
Should.Throw<OperationCanceledException>(() => svc.SignatureHelp(new SignatureHelpRequest("ctx.GetTag(", 1, 12), Cancelled()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_request_token_reaches_the_catalog()
|
||||
{
|
||||
var catalog = new CapturingCatalog();
|
||||
var svc = new ScriptAnalysisService(catalog);
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
await svc.CompleteAsync(new CompletionsRequest("return ctx.GetTag(\"\");", 1, 20), cts.Token);
|
||||
await svc.Hover(new HoverRequest("return ctx.GetTag(\"Plant/Dev/Speed\");", 1, 22), cts.Token);
|
||||
await svc.DiagnoseAsync(new DiagnoseRequest("return ctx.GetTag(\"{{equip}}/Nope\").Value;", "EQ-1"), cts.Token);
|
||||
|
||||
catalog.Seen.Count.ShouldBe(3);
|
||||
catalog.Seen.ShouldAllBe(t => t == cts.Token);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- endpoints
|
||||
|
||||
// A bare DefaultHttpContext reports "no body"; Kestrel sets this feature for a real POST.
|
||||
private sealed class HasBody : Microsoft.AspNetCore.Http.Features.IHttpRequestBodyDetectionFeature
|
||||
{
|
||||
public bool CanHaveBody => true;
|
||||
}
|
||||
|
||||
internal static (WebApplication App, CapturingCatalog Catalog) BuildApp()
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
var catalog = new CapturingCatalog();
|
||||
builder.Services.AddSingleton<IScriptTagCatalog>(catalog);
|
||||
builder.Services.AddScoped(sp => new ScriptAnalysisService(sp.GetRequiredService<IScriptTagCatalog>()));
|
||||
builder.Services.AddAuthorization();
|
||||
var app = builder.Build();
|
||||
app.MapScriptAnalysisEndpoints();
|
||||
return (app, catalog);
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<RouteEndpoint> Endpoints(WebApplication app)
|
||||
=> ((IEndpointRouteBuilder)app).DataSources.SelectMany(d => d.Endpoints).OfType<RouteEndpoint>().ToList();
|
||||
|
||||
[Fact]
|
||||
public async Task The_endpoint_binds_RequestAborted_through_to_the_catalog()
|
||||
{
|
||||
var (app, catalog) = BuildApp();
|
||||
var endpoint = Endpoints(app).Single(e => e.RoutePattern.RawText!.EndsWith("/completions"));
|
||||
|
||||
using var scope = app.Services.CreateScope();
|
||||
using var aborted = new CancellationTokenSource();
|
||||
var ctx = new DefaultHttpContext { RequestServices = scope.ServiceProvider, RequestAborted = aborted.Token };
|
||||
ctx.Request.Method = "POST";
|
||||
ctx.Request.ContentType = "application/json";
|
||||
var body = Encoding.UTF8.GetBytes("""{"codeText":"return ctx.GetTag(\"\");","line":1,"column":20}""");
|
||||
ctx.Request.Body = new MemoryStream(body);
|
||||
ctx.Request.ContentLength = body.Length;
|
||||
ctx.Response.Body = new MemoryStream();
|
||||
ctx.Features.Set<Microsoft.AspNetCore.Http.Features.IHttpRequestBodyDetectionFeature>(new HasBody());
|
||||
|
||||
await endpoint.RequestDelegate!(ctx);
|
||||
|
||||
ctx.Response.StatusCode.ShouldBe(200);
|
||||
catalog.Seen.ShouldHaveSingleItem().ShouldBe(aborted.Token);
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// R04:P2 — the editor used to build a brand-new <c>CSharpCompilation</c> (and re-bind the whole sandbox
|
||||
/// reference set) on every completion / hover / signature-help / diagnostics request. Each analysis now
|
||||
/// derives from ONE process-wide base compilation via <c>ReplaceSyntaxTree</c>, so the referenced-assembly
|
||||
/// binding is done once and shared.
|
||||
/// </summary>
|
||||
public sealed class AnalysisCompilationReuseTests
|
||||
{
|
||||
[Fact]
|
||||
public void Two_services_share_the_bound_reference_set()
|
||||
{
|
||||
// Two separate scopes (two HTTP requests) analysing different sources.
|
||||
var c1 = new ScriptAnalysisService().CompilationFor("return 1;");
|
||||
var c2 = new ScriptAnalysisService().CompilationFor("return 2;");
|
||||
|
||||
// Positive control: the two compilations really carry different user sources.
|
||||
c1.SyntaxTrees.Single().ToString().ShouldContain("return 1;");
|
||||
c2.SyntaxTrees.Single().ToString().ShouldContain("return 2;");
|
||||
c1.ShouldNotBeSameAs(c2);
|
||||
|
||||
// Derived from one bound base: the corlib symbol is the SAME instance, not a re-bound copy.
|
||||
c1.GetSpecialType(SpecialType.System_Object)
|
||||
.ShouldBeSameAs(c2.GetSpecialType(SpecialType.System_Object));
|
||||
c1.GetSpecialType(SpecialType.System_Object)
|
||||
.ShouldBeSameAs(ScriptAnalysisService.BaseCompilation.GetSpecialType(SpecialType.System_Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Repeated_analysis_in_one_scope_replaces_the_tree_rather_than_adding_one()
|
||||
{
|
||||
var svc = new ScriptAnalysisService();
|
||||
svc.CompilationFor("return 1;");
|
||||
var second = svc.CompilationFor("return 2;");
|
||||
|
||||
second.SyntaxTrees.Count().ShouldBe(1);
|
||||
second.SyntaxTrees.Single().ToString().ShouldContain("return 2;");
|
||||
second.SyntaxTrees.Single().ToString().ShouldNotContain("return 1;");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Diagnostics_are_unchanged_by_reuse()
|
||||
{
|
||||
// A clean script stays clean and an error stays an error across interleaved analyses on one service
|
||||
// — a stale tree leaking from the previous call would surface here.
|
||||
var svc = new ScriptAnalysisService();
|
||||
svc.Diagnose(new DiagnoseRequest("return ctx.Nope();")).Markers.ShouldContain(m => m.Severity == 8);
|
||||
svc.Diagnose(new DiagnoseRequest("""return (double)ctx.GetTag("A").Value * 2.0;""")).Markers.ShouldBeEmpty();
|
||||
svc.Diagnose(new DiagnoseRequest("var a = 1;\nreturn ctx.Nope();")).Markers
|
||||
.ShouldContain(m => m.Severity == 8 && m.StartLineNumber == 2);
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// Review D-a I-1 — the diagnostics markers cross the Blazor circuit (JS → .NET, for the Problems list) as ONE
|
||||
/// hub message. A non-C# paste yields several Roslyn errors per line; uncapped, a few thousand markers exceed
|
||||
/// the 1 MB hub limit and kill the circuit with every unsaved edit. Both the endpoint response and the circuit
|
||||
/// report are now bounded; these tests pin the bounds on the C# side of the contract.
|
||||
/// </summary>
|
||||
public sealed class MarkerPayloadBoundTests
|
||||
{
|
||||
// ~150 KB of log-ish text: every line is several C# errors.
|
||||
private static string NonCSharpPaste()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 0; sb.Length < 150 * 1024; i++)
|
||||
sb.Append($"2026-10-22 12:00:{i % 60:00} INFO [worker-{i}] value={i}, state=ok; next -> {i + 1} @@ ##\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_non_csharp_paste_returns_a_bounded_marker_set_that_still_holds_the_errors()
|
||||
{
|
||||
var markers = new ScriptAnalysisService().Diagnose(new DiagnoseRequest(NonCSharpPaste())).Markers;
|
||||
|
||||
markers.Count.ShouldBe(ScriptEditorLimits.MaxDiagnosticMarkers); // positive control: the cap was hit
|
||||
markers.ShouldAllBe(m => m.Message.Length <= ScriptEditorLimits.MaxDiagnosticMessageChars);
|
||||
markers[0].Severity.ShouldBe(8); // errors first — publish rejects, so must the editor
|
||||
markers.Select(m => (m.Severity, m.StartLineNumber, m.StartColumn))
|
||||
.ShouldBe(ScriptProblems.Order(markers).Select(m => (m.Severity, m.StartLineNumber, m.StartColumn)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_long_quoted_token_has_its_message_truncated()
|
||||
{
|
||||
var giant = new string('x', 20_000);
|
||||
var markers = new ScriptAnalysisService().Diagnose(new DiagnoseRequest($"return {giant};")).Markers;
|
||||
|
||||
markers.ShouldNotBeEmpty();
|
||||
markers.ShouldAllBe(m => m.Message.Length <= ScriptEditorLimits.MaxDiagnosticMessageChars);
|
||||
markers.ShouldContain(m => m.Message.EndsWith("…"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_small_script_is_not_truncated()
|
||||
{
|
||||
// Positive control for the cap: ordinary diagnostics pass through untouched.
|
||||
var markers = new ScriptAnalysisService().Diagnose(new DiagnoseRequest("return ctx.Nope();")).Markers;
|
||||
markers.ShouldContain(m => m.Severity == 8 && !m.Message.EndsWith("…"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_worst_case_circuit_report_fits_inside_the_sync_budget()
|
||||
{
|
||||
// The JS sends at most MaxReportedMarkers markers, each message cut to MaxReportedMessageChars. Worst case:
|
||||
// control characters (\u0001 → 6 JSON bytes, 7 when escaped again) and the largest line/column numbers.
|
||||
var message = new string('\u0001', ScriptEditorLimits.MaxReportedMessageChars);
|
||||
var marker = new DiagnosticMarker(8, int.MaxValue, int.MaxValue, int.MaxValue, int.MaxValue, message, "OTSCRIPT_SETVTAG_INERT");
|
||||
var report = Enumerable.Repeat(marker, ScriptEditorLimits.MaxReportedMarkers).ToArray();
|
||||
|
||||
var once = JsonSerializer.Serialize(new object[] { report, int.MaxValue });
|
||||
var twice = Encoding.UTF8.GetByteCount(JsonSerializer.Serialize(once));
|
||||
|
||||
twice.ShouldBeLessThan(ScriptEditorLimits.MaxEditorSyncBytes);
|
||||
ScriptEditorLimits.MaxEditorSyncBytes.ShouldBeLessThan((int)ScriptEditorLimits.HubMaximumReceiveMessageSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_truncated_report_says_how_many_were_left_out()
|
||||
{
|
||||
var shown = Enumerable.Range(1, 3).Select(i => new DiagnosticMarker(8, i, 1, i, 2, "m", "CS1002")).ToArray();
|
||||
|
||||
var report = new ScriptProblemReport(shown, 4321);
|
||||
report.Omitted.ShouldBe(4318);
|
||||
ScriptProblems.Summary(report).ShouldBe("3 errors (showing 3 of 4321)");
|
||||
|
||||
// Positive control: an untruncated report carries no suffix.
|
||||
ScriptProblems.Summary(new ScriptProblemReport(shown, 3)).ShouldBe("3 errors");
|
||||
ScriptProblemReport.Empty.Omitted.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_report_from_js_is_clamped_even_if_the_js_cap_regressed()
|
||||
{
|
||||
var flood = Enumerable.Range(1, 5000).Select(i => new DiagnosticMarker(4, i, 1, i, 2, new string('m', 2000), "W")).ToArray();
|
||||
|
||||
var report = ScriptProblemReport.FromEditor(flood, total: 5000);
|
||||
|
||||
report.Markers.Count.ShouldBe(ScriptEditorLimits.MaxReportedMarkers);
|
||||
report.Total.ShouldBe(5000);
|
||||
report.Markers.ShouldAllBe(m => m.Message.Length <= ScriptEditorLimits.MaxReportedMessageChars);
|
||||
ScriptProblemReport.FromEditor(null, 0).ShouldBe(ScriptProblemReport.Empty);
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// Source pins for the Monaco interop (AdminUI has no bUnit and no JS test runner). These do not prove the
|
||||
/// browser behaviour — the docker-dev live check does — they stop the specific regressions R04:P1/P9/S2/S3 fixed
|
||||
/// from quietly coming back: per-keystroke text sync, unsequenced diagnostics, the inlay-hints POST, and an
|
||||
/// unconsumed marker round-trip.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MonacoInteropSourceTests
|
||||
{
|
||||
private static string Read(params string[] relative)
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx")))
|
||||
dir = dir.Parent;
|
||||
dir.ShouldNotBeNull("could not locate the repository root");
|
||||
return File.ReadAllText(Path.Combine(new[] { dir!.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.AdminUI" }.Concat(relative).ToArray()));
|
||||
}
|
||||
|
||||
private static string Js() => Read("wwwroot", "js", "monaco-init.js");
|
||||
|
||||
[Fact]
|
||||
public void The_content_change_handler_does_not_ship_the_text_to_dotnet()
|
||||
{
|
||||
var js = Js();
|
||||
var handler = Regex.Match(js, @"onDidChangeModelContent\(function \(\) \{(?<body>.*?)\n \}\);", RegexOptions.Singleline);
|
||||
handler.Success.ShouldBeTrue("the content-change handler moved; update this pin");
|
||||
handler.Groups["body"].Value.ShouldNotContain("OnValueChanged");
|
||||
handler.Groups["body"].Value.ShouldNotContain("getValue");
|
||||
// Positive control: the text IS still reported — at blur.
|
||||
js.ShouldContain("onDidBlurEditorText");
|
||||
js.ShouldContain("\"OnValueChanged\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Diagnostics_are_generation_guarded_and_abortable()
|
||||
{
|
||||
var js = Js();
|
||||
js.ShouldContain("diagGeneration");
|
||||
js.ShouldContain("generation !== e.diagGeneration");
|
||||
js.ShouldContain("new AbortController()");
|
||||
js.ShouldContain("signal: signal");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Providers_honour_the_monaco_cancellation_token()
|
||||
{
|
||||
var js = Js();
|
||||
js.ShouldContain("token.onCancellationRequested");
|
||||
foreach (var path in new[] { "\"completions\"", "\"hover\"", "\"format\"", "\"signature-help\"" })
|
||||
js.ShouldContain("postAnalysis(" + path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_inlay_hints_provider_is_not_registered() => Js().ShouldNotContain("registerInlayHintsProvider");
|
||||
|
||||
[Fact]
|
||||
public void Markers_cross_to_dotnet_only_for_a_subscribed_problems_list()
|
||||
{
|
||||
Js().ShouldContain("if (e.reportMarkers) {");
|
||||
Read("Components", "Shared", "MonacoEditor.razor").ShouldContain("reportMarkers = MarkersChanged.HasDelegate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_markers_report_is_capped_before_it_crosses_the_circuit()
|
||||
{
|
||||
// Review I-1: the only OnMarkersChanged invoke sends the capped report, never the raw marker array.
|
||||
var js = Js();
|
||||
Regex.Matches(js, "\"OnMarkersChanged\"").Count.ShouldBe(1);
|
||||
js.ShouldContain("dotNetRef.invokeMethodAsync(\"OnMarkersChanged\", report.markers, report.total)");
|
||||
js.ShouldContain("const report = markerReport(e, markers);");
|
||||
js.ShouldContain(".slice(0, max)");
|
||||
js.ShouldContain("m.message.substring(0, maxChars - 1)");
|
||||
// The caps come from the C# constants, so the JS and ScriptProblemReport.FromEditor agree.
|
||||
var razor = Read("Components", "Shared", "MonacoEditor.razor");
|
||||
razor.ShouldContain("maxReportedMarkers = ScriptEditorLimits.MaxReportedMarkers");
|
||||
razor.ShouldContain("maxReportedMessageChars = ScriptEditorLimits.MaxReportedMessageChars");
|
||||
razor.ShouldContain("ScriptProblemReport.FromEditor(markers, total)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_oversize_text_is_announced_at_blur_not_only_at_save()
|
||||
{
|
||||
Js().ShouldContain("invokeMethodAsync(\"OnSyncRefused\", serializedBytes(v))");
|
||||
Read("Components", "Shared", "MonacoEditor.razor").ShouldContain("public void OnSyncRefused(int bytes)");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Components", "Pages", "ScriptEdit.razor")]
|
||||
[InlineData("Components", "Shared", "Uns", "VirtualTagModal.razor")]
|
||||
public void Script_hosts_read_the_editor_on_save_and_render_the_problems_list(params string[] path)
|
||||
{
|
||||
var src = Read(path);
|
||||
src.ShouldContain("GetValueAsync()");
|
||||
src.ShouldContain("<ScriptProblemsList");
|
||||
src.ShouldContain("RevealLineAsync(");
|
||||
src.ShouldContain("MarkersChanged=");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// R04:P1 / S2 — the Monaco editor no longer ships its text over the circuit per keystroke; the page reads it on
|
||||
/// demand at blur / Save. That single read is one hub message, so the Blazor hub's receive limit is raised from
|
||||
/// the 32 KB default to 1 MB, and the JS refuses (rather than sends) a value that would not fit.
|
||||
/// </summary>
|
||||
public sealed class MonacoSyncTests
|
||||
{
|
||||
private static HubOptions ComponentHubOptions(IServiceProvider sp)
|
||||
{
|
||||
// ComponentHub is internal to Microsoft.AspNetCore.Components.Server; resolve its options by type name.
|
||||
var hubType = Type.GetType("Microsoft.AspNetCore.Components.Server.ComponentHub, Microsoft.AspNetCore.Components.Server", throwOnError: true)!;
|
||||
var optionsType = typeof(IOptions<>).MakeGenericType(typeof(HubOptions<>).MakeGenericType(hubType));
|
||||
var options = sp.GetRequiredService(optionsType);
|
||||
return (HubOptions)optionsType.GetProperty("Value")!.GetValue(options)!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_blazor_hub_accepts_a_1_MB_message()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddAdminUI();
|
||||
using var sp = services.BuildServiceProvider();
|
||||
|
||||
ComponentHubOptions(sp).MaximumReceiveMessageSize.ShouldBe(1024 * 1024);
|
||||
ScriptEditorLimits.HubMaximumReceiveMessageSize.ShouldBe(1024 * 1024);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_AddAdminUI_the_default_limit_is_the_32_KB_that_truncated_large_scripts()
|
||||
{
|
||||
// Positive control for the test above: the 1 MB comes from AddAdminUI, not from a framework default.
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddRazorComponents().AddInteractiveServerComponents();
|
||||
using var sp = services.BuildServiceProvider();
|
||||
|
||||
(ComponentHubOptions(sp).MaximumReceiveMessageSize ?? 32 * 1024).ShouldBe(32 * 1024);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_editor_sync_budget_leaves_headroom_inside_the_hub_limit_and_fits_a_100_KB_script()
|
||||
{
|
||||
ScriptEditorLimits.MaxEditorSyncBytes.ShouldBeLessThan((int)ScriptEditorLimits.HubMaximumReceiveMessageSize);
|
||||
ScriptEditorLimits.MaxEditorSyncBytes.ShouldBeGreaterThan(4 * 100 * 1024); // 100 KB even after escaping twice
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// R04:S3 / S2 — the marker round-trip now feeds a real Problems list (MarkersChanged → list → RevealLineAsync),
|
||||
/// and the dead inlay-hints endpoint is gone. The Razor wiring is live-verified; the ordering and wording it
|
||||
/// renders are pinned here.
|
||||
/// </summary>
|
||||
public sealed class ProblemsListTests
|
||||
{
|
||||
private static DiagnosticMarker M(int sev, int line, int col, string code = "X") => new(sev, line, col, line, col + 1, "msg", code);
|
||||
|
||||
[Fact]
|
||||
public void Problems_are_ordered_errors_first_then_by_position()
|
||||
{
|
||||
var ordered = ScriptProblems.Order(new[]
|
||||
{
|
||||
M(4, 1, 1, "W1"), M(8, 9, 2, "E3"), M(8, 2, 5, "E2"), M(8, 2, 1, "E1"), M(2, 1, 1, "I1"),
|
||||
});
|
||||
|
||||
ordered.Select(m => m.Code).ShouldBe(new[] { "E1", "E2", "E3", "W1", "I1" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_or_empty_markers_order_to_an_empty_list() => ScriptProblems.Order(null).ShouldBeEmpty();
|
||||
|
||||
[Theory]
|
||||
[InlineData(8, "Error")]
|
||||
[InlineData(4, "Warning")]
|
||||
[InlineData(2, "Info")]
|
||||
[InlineData(1, "Hint")]
|
||||
public void Severity_labels_follow_monaco_values(int severity, string label)
|
||||
=> ScriptProblems.SeverityLabel(severity).ShouldBe(label);
|
||||
|
||||
[Fact]
|
||||
public void Summary_counts_errors_and_warnings()
|
||||
{
|
||||
ScriptProblems.Summary(Array.Empty<DiagnosticMarker>()).ShouldBe("No problems");
|
||||
ScriptProblems.Summary(new[] { M(8, 1, 1) }).ShouldBe("1 error");
|
||||
ScriptProblems.Summary(new[] { M(8, 1, 1), M(8, 2, 1), M(4, 3, 1) }).ShouldBe("2 errors, 1 warning");
|
||||
ScriptProblems.Summary(new[] { M(4, 1, 1), M(4, 2, 1), M(2, 3, 1) }).ShouldBe("2 warnings, 1 other");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_inlay_hints_endpoint_is_gone()
|
||||
{
|
||||
// The always-empty inlay-hints endpoint (and the JS provider that POSTed the full source to it on every
|
||||
// edit) were removed. Positive control: the other five routes are still mapped.
|
||||
var (app, _) = AnalysisCancellationTests.BuildApp();
|
||||
var routes = AnalysisCancellationTests.Endpoints(app).Select(e => e.RoutePattern.RawText!).ToList();
|
||||
|
||||
routes.ShouldNotContain(r => r.Contains("inlay", StringComparison.OrdinalIgnoreCase));
|
||||
foreach (var r in new[] { "/diagnostics", "/completions", "/hover", "/signature-help", "/format" })
|
||||
routes.ShouldContain(x => x.EndsWith(r));
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// R04:P3 — the catalog used to build a HashSet of every fleet path and sort it on every completion keystroke,
|
||||
/// and hover did a linear scan. The snapshot now carries a sorted, de-duplicated <c>string[]</c> (prefix-searched
|
||||
/// with a binary search) and an Ordinal path→info dictionary, built once per snapshot.
|
||||
/// </summary>
|
||||
public sealed class ScriptTagCatalogIndexTests
|
||||
{
|
||||
private const int Max = 200;
|
||||
|
||||
// The previous per-keystroke implementation, kept here as the oracle the index must match exactly.
|
||||
private static IReadOnlyList<string> Naive(IReadOnlyList<ScriptTagInfo> entries, string? filter)
|
||||
{
|
||||
IEnumerable<string> q = new HashSet<string>(entries.Select(e => e.Path), StringComparer.OrdinalIgnoreCase);
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
q = q.Where(p => p.StartsWith(filter, StringComparison.OrdinalIgnoreCase));
|
||||
return q.OrderBy(p => p, StringComparer.OrdinalIgnoreCase).Take(Max).ToList();
|
||||
}
|
||||
|
||||
private static ScriptTagInfo Tag(string path) => new(path, "Tag", "Float", "DRV-1");
|
||||
|
||||
[Fact]
|
||||
public void Prefix_search_is_case_insensitive_sorted_and_bounded()
|
||||
{
|
||||
var index = ScriptTagCatalogIndex.Build(new[]
|
||||
{
|
||||
Tag("Plant/b/Speed"), Tag("plant/A/Temp"), Tag("Other/X"), Tag("Plant/a/Flow"),
|
||||
});
|
||||
|
||||
index.FindByPrefix("PLANT/", Max).ShouldBe(new[] { "Plant/a/Flow", "plant/A/Temp", "Plant/b/Speed" });
|
||||
index.FindByPrefix("other", Max).ShouldBe(new[] { "Other/X" });
|
||||
index.FindByPrefix("zzz", Max).ShouldBeEmpty();
|
||||
index.FindByPrefix(null, 2).Count.ShouldBe(2);
|
||||
index.FindByPrefix(" ", Max).Count.ShouldBe(4); // whitespace filter = unfiltered, as before
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Case_only_duplicates_collapse_to_the_first_seen_casing()
|
||||
{
|
||||
// HashSet(OrdinalIgnoreCase) kept the FIRST-added casing; the index must too.
|
||||
var index = ScriptTagCatalogIndex.Build(new[] { Tag("A/speed"), Tag("A/Speed"), Tag("A/SPEED") });
|
||||
index.SortedPaths.ShouldBe(new[] { "A/speed" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Exact_lookup_is_ordinal_and_keeps_the_first_entry()
|
||||
{
|
||||
var first = new ScriptTagInfo("A/B", "Tag", "Float", "DRV-1");
|
||||
var index = ScriptTagCatalogIndex.Build(new[] { first, new ScriptTagInfo("A/B", "Virtual tag", "Int32", null) });
|
||||
|
||||
index.TryGet("A/B").ShouldBeSameAs(first);
|
||||
index.TryGet("a/b").ShouldBeNull(); // case-mismatched path does not resolve at runtime
|
||||
index.TryGet("A/C").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Matches_the_previous_implementation_on_a_generated_fleet()
|
||||
{
|
||||
// Deterministic seed — a mixed-case fleet with case-only duplicates and shared prefixes.
|
||||
var rng = new Random(745);
|
||||
string Seg() => new string(Enumerable.Range(0, rng.Next(1, 4)).Select(_ => "abAB_/1"[rng.Next(7)]).ToArray());
|
||||
var entries = Enumerable.Range(0, 3000).Select(_ => Tag(Seg() + "/" + Seg() + "/" + Seg())).ToList();
|
||||
var index = ScriptTagCatalogIndex.Build(entries);
|
||||
|
||||
var filters = new List<string?> { null, "", "a", "A", "ab", "aB/", "b/", "_", "1", "/", "zz" };
|
||||
filters.AddRange(entries.Take(50).Select(e => e.Path[..Math.Min(e.Path.Length, rng.Next(1, 6))]));
|
||||
|
||||
foreach (var f in filters)
|
||||
index.FindByPrefix(f, Max).ShouldBe(Naive(entries, f), $"filter '{f}'");
|
||||
// Positive control: the generated fleet really exercises truncation and filtering.
|
||||
Naive(entries, null).Count.ShouldBe(Max);
|
||||
Naive(entries, "ab").Count.ShouldBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// R04:S1 — <c>ctx.SetVirtualTag(...)</c> compiles and publishes but is dropped at runtime (single-tag mode).
|
||||
/// Only a hover used to say so; <c>Diagnose</c> now emits an <c>OTSCRIPT_SETVTAG_INERT</c> WARNING marker so the
|
||||
/// Problems list shows it. A warning, never an error: publish accepts the script, so the editor must too.
|
||||
/// </summary>
|
||||
public sealed class SetVirtualTagInertDiagnosticTests
|
||||
{
|
||||
private const string InertCode = "OTSCRIPT_SETVTAG_INERT";
|
||||
private static readonly ScriptAnalysisService Svc = new();
|
||||
private static IReadOnlyList<DiagnosticMarker> Diag(string code) => Svc.Diagnose(new DiagnoseRequest(code)).Markers;
|
||||
|
||||
[Fact]
|
||||
public void SetVirtualTag_call_gets_a_warning_marker_on_its_line()
|
||||
{
|
||||
var markers = Diag("var a = 1;\nvar b = 2;\nctx.SetVirtualTag(\"Other\", a + b);\nreturn 0;");
|
||||
|
||||
var m = markers.ShouldHaveSingleItem();
|
||||
m.Code.ShouldBe(InertCode);
|
||||
m.Severity.ShouldBe(4); // Monaco warning
|
||||
m.StartLineNumber.ShouldBe(3);
|
||||
m.StartColumn.ShouldBe(1);
|
||||
m.Message.ShouldContain("SetVirtualTag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_SetVirtualTag_call_is_marked()
|
||||
=> Diag("ctx.SetVirtualTag(\"A\", 1);\nctx.SetVirtualTag(\"B\", 2);\nreturn 0;")
|
||||
.Count(m => m.Code == InertCode).ShouldBe(2);
|
||||
|
||||
[Theory]
|
||||
[InlineData("return ctx.GetTag(\"A\").Value;")] // a different ctx member
|
||||
[InlineData("return 0; // ctx.SetVirtualTag(\"A\", 1)")] // a comment
|
||||
[InlineData("var s = \"ctx.SetVirtualTag(\\\"A\\\", 1)\"; return s;")] // a string literal
|
||||
public void Non_calls_are_not_marked(string code)
|
||||
{
|
||||
var markers = Diag(code);
|
||||
markers.ShouldNotContain(m => m.Code == InertCode);
|
||||
markers.ShouldNotContain(m => m.Severity == 8); // the probe scripts themselves are valid
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_warning_does_not_block_and_publish_accepts_the_script()
|
||||
{
|
||||
// Editor accepts ⇔ publish accepts: no error-severity marker, and the real publish compiler accepts it.
|
||||
const string source = "ctx.SetVirtualTag(\"Other\", 1);\nreturn 0;";
|
||||
Diag(source).ShouldNotContain(m => m.Severity == 8);
|
||||
using var evaluator = ScriptEvaluator<VirtualTagContext, object>.Compile(source);
|
||||
evaluator.ShouldNotBeNull();
|
||||
}
|
||||
}
|
||||
+60
@@ -414,4 +414,64 @@ public sealed class AddressSpaceComposerDualNamespaceTests
|
||||
AddressSpaceApplier.UnsReferenceNodeWritable(c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == id)).ShouldBeTrue(id);
|
||||
AddressSpaceApplier.UnsReferenceNodeWritable(c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r-bits")).ShouldBeFalse();
|
||||
}
|
||||
/// <summary>
|
||||
/// #741 (Modbus lane U3, follow-up F1): the Modbus driver encodes String[] and BitInRegister[] holding-register
|
||||
/// arrays, so an authored-<c>ReadWrite</c> array of each materialises WRITABLE — raw node and UNS mirror alike. A
|
||||
/// Coils array authored with a non-Bool type (the resolver coerces a bit region to Bool) is writable too. Shapes
|
||||
/// the driver refuses stay read-only on the SAME driver: a BitInRegister array over the FC16 cap, a String array
|
||||
/// with no <c>stringLength</c>, and a string-only (<c>addressString</c>) array whose family the composer cannot
|
||||
/// know — the negative controls.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Modbus_string_and_bit_in_register_arrays_materialise_writable_and_refused_shapes_stay_read_only()
|
||||
{
|
||||
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 = "{}" };
|
||||
var dev = new Device { DeviceId = "d1", DriverInstanceId = "drv-mb", Name = "plc", DeviceConfig = "{}" };
|
||||
static Tag Arr(string id, string dataType, string tagConfig) => new()
|
||||
{
|
||||
TagId = id, DeviceId = "d1", Name = id, DataType = dataType, AccessLevel = TagAccessLevel.ReadWrite,
|
||||
TagConfig = tagConfig,
|
||||
};
|
||||
var tags = new[]
|
||||
{
|
||||
Arr("names", "String", "{\"region\":\"HoldingRegisters\",\"address\":100,\"dataType\":\"String\",\"stringLength\":8,\"isArray\":true,\"arrayLength\":4}"),
|
||||
Arr("flags", "Boolean", "{\"region\":\"HoldingRegisters\",\"address\":200,\"dataType\":\"BitInRegister\",\"bitIndex\":3,\"isArray\":true,\"arrayLength\":8}"),
|
||||
Arr("legacyCoils", "Int16", "{\"region\":\"Coils\",\"address\":0,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":8}"),
|
||||
Arr("overCap", "Boolean", "{\"region\":\"HoldingRegisters\",\"address\":300,\"dataType\":\"BitInRegister\",\"isArray\":true,\"arrayLength\":124}"),
|
||||
Arr("noLength", "String", "{\"region\":\"HoldingRegisters\",\"address\":400,\"dataType\":\"String\",\"isArray\":true,\"arrayLength\":4}"),
|
||||
Arr("stringOnly", "Int16", "{\"addressString\":\"40501:S::4\",\"isArray\":true,\"arrayLength\":4}"),
|
||||
};
|
||||
var area = new UnsArea { UnsAreaId = "a", ClusterId = "c1", Name = "filling" };
|
||||
var line = new UnsLine { UnsLineId = "l", UnsAreaId = "a", Name = "line-1" };
|
||||
var equip = new Equipment { EquipmentId = "e", UnsLineId = "l", Name = "station-1", MachineCode = "S1" };
|
||||
var refs = new[]
|
||||
{
|
||||
new UnsTagReference { UnsTagReferenceId = "r-names", EquipmentId = "e", TagId = "names" },
|
||||
new UnsTagReference { UnsTagReferenceId = "r-flags", EquipmentId = "e", TagId = "flags" },
|
||||
new UnsTagReference { UnsTagReferenceId = "r-over", EquipmentId = "e", TagId = "overCap" },
|
||||
};
|
||||
|
||||
var c = AddressSpaceComposer.Compose(
|
||||
new[] { area }, new[] { line }, new[] { equip }, new[] { mb }, Array.Empty<ScriptedAlarm>(),
|
||||
unsTagReferences: refs, tags: tags, rawFolders: new[] { folder }, devices: new[] { dev });
|
||||
|
||||
foreach (var writable in new[] { "names", "flags", "legacyCoils" })
|
||||
{
|
||||
var plan = c.RawTags.Single(t => t.Name == writable);
|
||||
plan.ArrayWritable.ShouldBeTrue(writable);
|
||||
AddressSpaceApplier.RawNodeWritable(plan).ShouldBeTrue(writable);
|
||||
}
|
||||
foreach (var refused in new[] { "overCap", "noLength", "stringOnly" })
|
||||
{
|
||||
var plan = c.RawTags.Single(t => t.Name == refused);
|
||||
plan.IsArray.ShouldBeTrue(refused); // still an array node — just not a writable one
|
||||
plan.ArrayWritable.ShouldBeFalse(refused);
|
||||
AddressSpaceApplier.RawNodeWritable(plan).ShouldBeFalse(refused);
|
||||
}
|
||||
|
||||
foreach (var id in new[] { "r-names", "r-flags" })
|
||||
AddressSpaceApplier.UnsReferenceNodeWritable(c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == id)).ShouldBeTrue(id);
|
||||
AddressSpaceApplier.UnsReferenceNodeWritable(c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r-over")).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user