Merge branch 'master' into b626-626
This commit is contained in:
@@ -1105,6 +1105,31 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
|
||||
primary-host claim backs off 2 s → 30 s; writes encode the birth's own datatype (unsupported birth types
|
||||
`BadNotSupported`).
|
||||
- **FOCAS / AbLegacy:** background loops are joined (≤ 5 s) on shutdown.
|
||||
- **Re-Initialize without teardown (#630):** FOCAS, AbLegacy, AbCip, TwinCAT, S7 and OpcUaClient `InitializeAsync`
|
||||
first release the prior device/session state (cancel, ≤ 5 s join, dispose — incl. the AbCip/FOCAS alarm
|
||||
projections) — the device-state half of `ShutdownAsync` only; the poll engine is kept and the actor re-subscribes
|
||||
after every successful init. AbCip publishes fresh tag tables by reference swap (a second init used to throw
|
||||
"tag name collision" on `AdoptConfigDuringInit`); S7 swaps `Plc` under `_gate`.
|
||||
- **S7 (#632/#638):** STRING/WSTRING/CHAR/WCHAR and DTL/DATE/TOD/TIME/S5TIME codecs (`S7ValueCodecs`; TOD/TIME/S5TIME
|
||||
surface as Int32 ms); an undecodable buffer or unrepresentable write is `BadOutOfRange` with no health impact.
|
||||
Optional `localTsap`/`remoteTsap`/`connectionType` override the slot TSAP (`remoteTsap` ⊕ `connectionType`). Test
|
||||
Connect reads 2 bytes of `MB0` so a PUT/GET-disabled CPU fails the probe. The negotiated PDU size is logged and on
|
||||
`S7Driver.GetDiagnostics()` only — there is no driver-diagnostics surface on `/hosts`.
|
||||
- **TwinCAT (#637):** TIME/TOD publish Float64 ms, DATE/DT a UTC DateTime; a tag authored `UDInt` keeps the raw counter
|
||||
(and keeps historizing — DateTime is not historized).
|
||||
- **AbLegacy (#636):** `AbLegacyAddress` (in `AbLegacy.Contracts`) is family-aware: PLC-5 `I:`/`O:` indices are OCTAL
|
||||
and are emitted to libplctag as decimal (libplctag's `pccc.c` parses decimal only). MicroLogix function files and
|
||||
indirect `N7:[N7:0]` addresses parse but read `BadNotSupported`. One check (`AbLegacyAddress.Validate`) backs the
|
||||
editor (family-independent) and the deploy gate (family from the tag's `DeviceConfig.PlcFamily`); an unparseable
|
||||
address reads `BadNodeIdUnknown` for that tag only.
|
||||
- **OpcUaClient (#635):** OperationLimits are honoured by the SDK session (`SessionClientBatched`) — do not re-add
|
||||
driver-side chunking. Subscription tuning keys are range-checked at `ParseOptions`. `discoveryUrl` endpoints go
|
||||
AFTER configured ones; more than one discovered server without `discoveryServerUri` fails the connect. Session
|
||||
events are the OTel counter `otopcua.driver.opcuaclient.events`. `HistoryAggregateType` carries the full Part 13
|
||||
set; an aggregate the gateway can't serve is `BadAggregateNotSupported` per node.
|
||||
- **MTConnect (#627/#628):** TIME_SERIES reads as a variable-length `double[]` (Float64 only); a count disagreeing
|
||||
with `sampleCount` is `BadDecodingError`. One MTConnect driver = one agent client = at most one `/raw` Device
|
||||
(`MTConnectMultipleDevices` deploy error; `RawTreeService.CreateDeviceAsync` refuses a second).
|
||||
- **Driver removal (#609):** removing a driver releases its capability-invoker pipelines and resilience
|
||||
tracker, *then* evicts its telemetry snapshots. ⚠️ Both are **tombstoned**, not just cleared: the removed
|
||||
child stops asynchronously and its in-flight subscribe/connect still completes through the invoker (and
|
||||
@@ -1193,6 +1218,15 @@ ack is refused `BadInvalidState` off-Primary** via `NativeAlarmAckPrimaryGate` (
|
||||
changes, so the boot window before the first redundancy snapshot is closed — the same fix covers the MQTT
|
||||
Sparkplug primary-host claim). A native re-ack is still re-sent to the driver (the retry path, by design).
|
||||
|
||||
**Condition children are instance-scoped (#629).** Every child of a materialised condition carries
|
||||
`{ConditionId}/{browse path}` in the condition's namespace (e.g. `…/temp_hi/ShelvingState/UnshelveTime`), assigned by
|
||||
the `OtOpcUaNodeManager.New` override — the SDK default kept each child on the TYPE's ns=0 id, so a direct Read hit
|
||||
the type (or `BadNodeIdUnknown`), translate failed, and children sat outside the ACL (the slash join now puts them
|
||||
under their condition's scope). ⚠️ The shelve methods resolve by the `AlarmConditionType_ShelvingState_*` ids only
|
||||
because `New` adopts those (a fixed set) as their `MethodDeclarationId`; calls by the `ShelvedStateMachineType_*` id
|
||||
are no longer supported (pinned by a test). Condition teardown must forget the whole `PredefinedNodes` subtree
|
||||
(`ForgetPredefinedSubtree`) or a removed condition's children keep answering.
|
||||
|
||||
## Historian / HistoryRead
|
||||
|
||||
**Backend: HistorianGateway (sole historian backend).** As of the gateway-integration cutover, the
|
||||
|
||||
@@ -116,6 +116,40 @@ that `ConditionClassId` / `ConditionClassName` do too. The two class fields are
|
||||
> a live↔history join key. Correlate on `ConditionId` / the RawPath instead. (Pre-existing; the
|
||||
> live-path fix above does not change the history path.)
|
||||
|
||||
### Condition child nodes are instance-scoped (#629)
|
||||
|
||||
Every child of a materialised condition — `ActiveState`, `ActiveState/Id`, `AckedState/Id`,
|
||||
`ShelvingState/UnshelveTime`, `Message`, `Severity`, `Retain`, the method nodes, … — carries the NodeId
|
||||
**`{ConditionId}/{browse path}`** in the condition's own namespace: e.g.
|
||||
`ns=<raw>;s=Plant/Modbus/dev1/temp_hi/ShelvingState/UnshelveTime`, or
|
||||
`ns=<uns>;s=<ScriptedAlarmId>/ActiveState/Id`. So `TranslateBrowsePathsToNodeIds` from the condition
|
||||
resolves, and a direct Read or data-change subscription on a child returns **this condition's** value.
|
||||
|
||||
Before #629 the children kept ns=0 ids: `Create(…, assignNodeIds: true)` asks the node manager's
|
||||
`INodeIdFactory.New`, and the SDK default returns the node's current id — the condition type's
|
||||
instance-declaration id (`i=9189` for UnshelveTime) or an SDK-generated ns=0 number. Every translate of
|
||||
a child path returned `BadNoMatch`; a Read of a browsed child id was either `BadNodeIdUnknown` or served
|
||||
from the **type** (UnshelveTime read `0` on a condition timed-shelved for a minute). `OtOpcUaNodeManager.New`
|
||||
now derives the id. Event delivery was never affected (events copy the instance's fields; clients select
|
||||
them by type + browse path), and neither were the Part 9 methods — calling them by the type's method id
|
||||
(`MethodIds.AcknowledgeableConditionType_Acknowledge`, …) still works, because the SDK matches a method on
|
||||
its declaration id. ⚠️ The ShelvingState methods needed help here: their SDK declaration id is the
|
||||
`ShelvedStateMachineType`'s (`i=2948`…), and they were only ever found by the `AlarmConditionType_ShelvingState_*`
|
||||
id (`i=9212`…) because that WAS their NodeId. `New` adopts that original id as the method's
|
||||
`MethodDeclarationId` (a fixed set of those three ids — no address-space lookup, which would take other
|
||||
node managers' locks under ours), so OneShotShelve/TimedShelve/Unshelve by the
|
||||
`AlarmConditionType_ShelvingState_*` id keep working, as do calls by the condition's own instance method
|
||||
ids (`{ConditionId}/ShelvingState/TimedShelve`, on the condition or its ShelvingState). Calling them by the
|
||||
`ShelvedStateMachineType_*` id returns `BadMethodInvalid` — a pinned trade-off.
|
||||
|
||||
- **ACL:** the slash-joined id makes a child's ACL scope its condition's scope plus the browse path, so
|
||||
with `Security:Acl:Enabled` a child is governed exactly like its condition (a grant covering the
|
||||
condition covers its children; one that does not, denies them). Before #629 a child was in ns=0 and
|
||||
bypassed the data-plane gate entirely.
|
||||
- **Removal:** removing or rebuilding a condition forgets its whole subtree, so a removed condition's
|
||||
children answer `BadNodeIdUnknown`, not their last value.
|
||||
- Guarded over the wire by `ConditionChildNodeIdTests`.
|
||||
|
||||
### Condition source-data Quality (#477)
|
||||
|
||||
`ConditionType.Quality` reports the quality of the condition's source data. It was never assigned, and
|
||||
|
||||
@@ -431,6 +431,17 @@ RetrievalMode.Integral`) — no client-side scaling. (This replaces the retired
|
||||
likewise a native gateway mode. Bucket status codes and timestamps are preserved unchanged; empty / null
|
||||
buckets surface as `BadNoData`.
|
||||
|
||||
### Aggregate set and `BadAggregateNotSupported` (#285)
|
||||
|
||||
`HistoryAggregateType` carries the wider Part 13 set (Interpolative, TimeAverage, Start, End, Delta, Range,
|
||||
Min/MaxActualTime, Start/EndBound, DeltaBounds, DurationGood/Bad, PercentGood/Bad, WorstQuality,
|
||||
NumberOfTransitions, AnnotationCount, StandardDeviationPopulation, VariancePopulation); `OtOpcUaNodeManager.MapAggregate`
|
||||
maps each standard aggregate NodeId. The **HistorianGateway** backend only has retrieval modes for the original
|
||||
five (Average, Minimum, Maximum, Total, Count): for any other member `AggregateModeMapper` throws
|
||||
`HistoryAggregateNotSupportedException`, which the HistoryRead path turns into `BadAggregateNotSupported` for that
|
||||
node only (not a generic failure, and not counted as a backend fault). The OPC UA Client driver forwards every
|
||||
member upstream. A NodeId outside the mapped set (e.g. `StandardDeviationSample`) is still `BadAggregateNotSupported`.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- **Processed and AtTime are single-shot** (no continuation points). Unlike Raw, neither
|
||||
|
||||
@@ -99,8 +99,11 @@ rather than surfacing as a runtime surprise.
|
||||
### History passthrough
|
||||
|
||||
`IHistoryProvider` forwards `HistoryRead` to the upstream server's own historian.
|
||||
Raw, processed (Average / Minimum / Maximum / Total / Count aggregates mapped to
|
||||
OPC UA Part 13 standard aggregate NodeIds), and at-time reads are supported; each
|
||||
Raw, processed (the full `HistoryAggregateType` set — Average / Minimum / Maximum / Total / Count plus
|
||||
Interpolative, TimeAverage, Start, End, Delta, Range, Min/Max ActualTime, Start/EndBound, DeltaBounds,
|
||||
DurationGood/Bad, PercentGood/Bad, WorstQuality, NumberOfTransitions, AnnotationCount and the
|
||||
population StandardDeviation/Variance — each mapped to its OPC UA Part 13 standard aggregate NodeId; #285),
|
||||
and at-time reads are supported; each
|
||||
returned `DataValue` keeps its upstream `StatusCode` and timestamps verbatim.
|
||||
Event-history (`ReadEventsAsync`) is left at the interface default — the
|
||||
interface doesn't yet carry the EventFilter surface needed to forward it.
|
||||
@@ -109,7 +112,67 @@ interface doesn't yet carry the EventFilter surface needed to forward it.
|
||||
|
||||
`AutoAcceptCertificates` accepts any self-signed / untrusted server certificate.
|
||||
It is **dev-only** — leave it `false` in production so a MITM against the
|
||||
opc.tcp channel fails closed.
|
||||
opc.tcp channel fails closed. The SDK rejects a **revoked** certificate
|
||||
(`BadCertificateRevoked` / `BadCertificateIssuerRevoked`) before the auto-accept handler runs, and the handler
|
||||
also refuses them explicitly — defence in depth (#277), not a behaviour change.
|
||||
|
||||
`CertificateValidation` (defaults change nothing):
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `rejectUnknownRevocationStatus` | `false` | Reject a server certificate whose revocation status cannot be determined (no CRL for its issuer, or a stale one). Leave `false` for brownfield sites without CRL infrastructure. **Only effective with `autoAcceptCertificates` off** — the auto-accept handler accepts every suppressible error, including revocation-unknown. |
|
||||
| `issuerStorePath` | `null` (= `%LocalAppData%/OtOpcUa/pki/issuers`) | Directory used as the trusted-issuer store. The SDK reads issuer CRLs (`*.crl`) from its `crl/` sub-folder, so this is how an operator points the driver at a managed CRL set. |
|
||||
|
||||
### Subscription tuning (#273)
|
||||
|
||||
The per-subscription parameters that used to be hardcoded are driver options; the defaults are the old values.
|
||||
|
||||
| Key | Default | Notes |
|
||||
|---|---|---|
|
||||
| `subscriptionPublishingInterval` | `null` | A .NET `TimeSpan` string (`"00:00:02"`), not a number. `null` = the interval the runtime requests per subscribe (floored at 50 ms). When set it overrides that value (floor still applies); monitored-item sampling follows it. |
|
||||
| `subscriptionKeepAliveCount` | `10` | `>= 1`. |
|
||||
| `subscriptionLifetimeCount` | `1000` | Must be `>= 3 x` keep-alive count (Part 4 §5.13.2). |
|
||||
| `subscriptionMaxNotificationsPerPublish` | `0` | `0` = server decides. |
|
||||
| `subscriptionPriority` | `0` | `0`-`255`. |
|
||||
|
||||
Out-of-range values (and a non-absolute `discoveryUrl`) are rejected when the options are loaded — driver creation / `InitializeAsync` fails once with a clear message rather than the driver faulting and retrying forever. The alarm subscription uses the same counts.
|
||||
*Per-tag* tuning (deadband / sampling interval / queue size, #274) is not implemented — it needs an
|
||||
`ISubscribable` signature change on Core.
|
||||
|
||||
### Server OperationLimits (#275)
|
||||
|
||||
Nothing driver-side: the SDK session already honours the remote server's
|
||||
`ServerCapabilities/OperationLimits`. `Opc.Ua.Client.Session` derives from `Opc.Ua.SessionClientBatched`,
|
||||
fetches the limits on open (`FetchOperationLimitsAsync`) and splits Read / Write / CreateMonitoredItems / Browse /
|
||||
HistoryRead requests that exceed them (a limit of `0` = unlimited). The driver issues one logical call per
|
||||
operation and a unit test pins the `Session : SessionClientBatched` relationship so an SDK change that dropped
|
||||
it is caught. (A driver-side splitter was tried and removed: it duplicated the SDK and, on a stalled link, held
|
||||
the driver gate for chunks x timeout.)
|
||||
|
||||
### Discovery URL (#278)
|
||||
|
||||
`discoveryUrl` (default `null`, e.g. `opc.tcp://lds:4840`): at `InitializeAsync` the driver runs `FindServers` on it
|
||||
and `GetEndpoints` on every returned server, keeping the endpoints matching `securityPolicy` + `securityMode`.
|
||||
Discovered URLs are tried **after** `endpointUrls`/`endpointUrl` (case-insensitive de-dup) — discovery can add
|
||||
fallbacks but never outranks or replaces the configured server. Because an LDS commonly registers several servers
|
||||
(identical line PLCs, a gateway plus a historian) and the first to accept a connection would be bound silently:
|
||||
|
||||
- `discoveryServerUri` set: only endpoints of the server with that ApplicationUri are used.
|
||||
- unset and exactly one distinct server discovered: that server is used.
|
||||
- unset and more than one: the connect attempt **fails** with an error naming the discovered ApplicationUris and
|
||||
telling the operator to set `discoveryServerUri`.
|
||||
|
||||
A discovery *network* failure (LDS down) is logged and non-fatal — the static list is used (note it is retried on
|
||||
every `InitializeAsync`, including comms-loss reconnects). The discovery channel itself is always unsecured (spec).
|
||||
|
||||
### Diagnostics counters (#276)
|
||||
|
||||
Session/publish events are published on the shared OTel meter as the counter
|
||||
`otopcua.driver.opcuaclient.events`, tagged `driver_instance` (the driver instance id) and `event` =
|
||||
`publish_response`, `notification` (items), `data_change`, `bad_status_value`, `publish_error`,
|
||||
`keepalive_miss`, `reconnect_attempt`, `reconnect_success`, `reconnect_exhausted`. A climbing `keepalive_miss` /
|
||||
`reconnect_attempt` rate is the signature of a flapping upstream. They are not part of `DriverHealth` or the
|
||||
`/hosts` panel.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -76,6 +76,16 @@ public static class OtOpcUaTelemetry
|
||||
/// </summary>
|
||||
public const string PollOverrunDriverInstanceTag = "driver_instance";
|
||||
|
||||
/// <summary>
|
||||
/// Session/publish-level events of an OPC UA Client driver instance (#276): the operator's view of a
|
||||
/// flapping upstream. Tagged <see cref="PollOverrunDriverInstanceTag"/> (<c>driver_instance</c>) +
|
||||
/// <c>event</c> = <c>publish_response|notification|data_change|bad_status_value|publish_error|
|
||||
/// keepalive_miss|reconnect_attempt|reconnect_success|reconnect_exhausted</c>.
|
||||
/// </summary>
|
||||
public static readonly Counter<long> OpcUaClientEvents =
|
||||
Meter.CreateCounter<long>("otopcua.driver.opcuaclient.events", unit: "{event}",
|
||||
description: "OPC UA Client driver session/publish events (event=publish_response|notification|data_change|bad_status_value|publish_error|keepalive_miss|reconnect_attempt|reconnect_success|reconnect_exhausted).");
|
||||
|
||||
// ---------------- VirtualTag / ScriptedAlarm engines ----------------
|
||||
|
||||
public static readonly Counter<long> VirtualTagEval =
|
||||
|
||||
@@ -132,6 +132,14 @@ public sealed record HistoryReadResult(
|
||||
byte[]? ContinuationPoint);
|
||||
|
||||
/// <summary>Aggregate function for processed history reads. Mirrors OPC UA Part 13 standard aggregates.</summary>
|
||||
/// <remarks>
|
||||
/// The first five members are the original set every historian backend serves. The remainder (#285) are
|
||||
/// the further standard Part 13 aggregates a server-side HistoryRead can forward to a backend that
|
||||
/// supports them (the OPC UA Client driver forwards all of them upstream). A backend that cannot serve a
|
||||
/// member must throw <see cref="HistoryAggregateNotSupportedException"/> — the server maps that to
|
||||
/// <c>BadAggregateNotSupported</c> rather than a generic failure. <b>Append only</b>: the numeric values
|
||||
/// are not persisted, but every <c>switch</c> over this enum must keep a total default.
|
||||
/// </remarks>
|
||||
public enum HistoryAggregateType
|
||||
{
|
||||
Average,
|
||||
@@ -139,6 +147,42 @@ public enum HistoryAggregateType
|
||||
Maximum,
|
||||
Total,
|
||||
Count,
|
||||
Interpolative,
|
||||
TimeAverage,
|
||||
Start,
|
||||
End,
|
||||
Delta,
|
||||
Range,
|
||||
MinimumActualTime,
|
||||
MaximumActualTime,
|
||||
StartBound,
|
||||
EndBound,
|
||||
DeltaBounds,
|
||||
DurationGood,
|
||||
DurationBad,
|
||||
PercentGood,
|
||||
PercentBad,
|
||||
WorstQuality,
|
||||
NumberOfTransitions,
|
||||
AnnotationCount,
|
||||
StandardDeviationPopulation,
|
||||
VariancePopulation,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown by a history backend when it cannot serve the requested <see cref="HistoryAggregateType"/>.
|
||||
/// The Server HistoryRead path translates it to <c>BadAggregateNotSupported</c> for that node only.
|
||||
/// </summary>
|
||||
public sealed class HistoryAggregateNotSupportedException : NotSupportedException
|
||||
{
|
||||
/// <summary>The aggregate the backend could not serve.</summary>
|
||||
public HistoryAggregateType Aggregate { get; }
|
||||
|
||||
/// <summary>Creates the exception for <paramref name="aggregate"/>.</summary>
|
||||
/// <param name="aggregate">The unsupported aggregate.</param>
|
||||
public HistoryAggregateNotSupportedException(HistoryAggregateType aggregate)
|
||||
: base($"The historian backend does not support the {aggregate} aggregate.")
|
||||
=> Aggregate = aggregate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+3
-1
@@ -79,11 +79,13 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
|
||||
HistoryAggregateType aggregate, CancellationToken cancellationToken)
|
||||
{
|
||||
// Mapped OUTSIDE the failure-recording try: an aggregate the gateway has no mode for is a client
|
||||
// request the server answers BadAggregateNotSupported, not a backend fault to count (#285).
|
||||
var mode = AggregateModeMapper.ToRetrievalMode(aggregate);
|
||||
try
|
||||
{
|
||||
// Total/Count are now native gateway retrieval modes — no client-side scaling
|
||||
// (unlike the Wonderware path that derived Total as Average × interval-seconds).
|
||||
var mode = AggregateModeMapper.ToRetrievalMode(aggregate);
|
||||
var buckets = new List<HistorianAggregateSample>();
|
||||
await foreach (var bucket in _client
|
||||
.ReadAggregateAsync(fullReference, startUtc, endUtc, mode, interval, cancellationToken)
|
||||
|
||||
+6
-1
@@ -25,7 +25,8 @@ internal static class AggregateModeMapper
|
||||
/// <summary>Maps an aggregate function to the gateway retrieval mode.</summary>
|
||||
/// <param name="aggregate">The driver-agnostic aggregate function.</param>
|
||||
/// <returns>The matching gateway <see cref="RetrievalMode"/>.</returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException">A future, unmapped enum member (fails the matrix guard).</exception>
|
||||
/// <exception cref="HistoryAggregateNotSupportedException">A defined member the gateway has no retrieval mode for.</exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException">An undefined enum value.</exception>
|
||||
public static RetrievalMode ToRetrievalMode(HistoryAggregateType aggregate) => aggregate switch
|
||||
{
|
||||
HistoryAggregateType.Average => RetrievalMode.TimeWeightedAverage,
|
||||
@@ -33,6 +34,10 @@ internal static class AggregateModeMapper
|
||||
HistoryAggregateType.Maximum => RetrievalMode.MaximumWithTime,
|
||||
HistoryAggregateType.Total => RetrievalMode.Integral,
|
||||
HistoryAggregateType.Count => RetrievalMode.Counter,
|
||||
// Part 13 aggregates the gateway has no retrieval mode for (#285) — an UNDEFINED enum value is a
|
||||
// programming error, but a defined-yet-unserved member is a normal client request the server
|
||||
// answers with BadAggregateNotSupported.
|
||||
_ when Enum.IsDefined(aggregate) => throw new HistoryAggregateNotSupportedException(aggregate),
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(aggregate), aggregate, "Unmapped HistoryAggregateType — add a RetrievalMode mapping."),
|
||||
};
|
||||
|
||||
+75
@@ -189,6 +189,81 @@ public sealed record OpcUaClientDriverOptions
|
||||
/// </summary>
|
||||
[Range(1, 60)]
|
||||
public int ProbeTimeoutSeconds { get; init; } = 15;
|
||||
|
||||
// ---- #273 per-subscription tuning (defaults are the values that used to be hardcoded) ----
|
||||
|
||||
/// <summary>
|
||||
/// Fixed publishing interval for every data-change subscription this driver creates. <c>null</c>
|
||||
/// (default) keeps the historical behaviour: the interval the runtime asks for per
|
||||
/// <c>SubscribeAsync</c> call, floored at 50 ms. When set it overrides the per-call value (the
|
||||
/// 50 ms floor still applies); monitored-item sampling intervals follow it.
|
||||
/// </summary>
|
||||
public TimeSpan? SubscriptionPublishingInterval { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Publish cycles with no data before the server sends a keep-alive (default 10). Must be >= 1.
|
||||
/// </summary>
|
||||
public uint SubscriptionKeepAliveCount { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Publish cycles with no Publish request before the server drops the subscription (default 1000).
|
||||
/// Must be at least 3x <see cref="SubscriptionKeepAliveCount"/> (OPC UA Part 4 §5.13.2).
|
||||
/// </summary>
|
||||
public uint SubscriptionLifetimeCount { get; init; } = 1000;
|
||||
|
||||
/// <summary>Max notifications per Publish response; <c>0</c> (default) = server decides.</summary>
|
||||
public uint SubscriptionMaxNotificationsPerPublish { get; init; }
|
||||
|
||||
/// <summary>Relative subscription priority 0-255 (default 0).</summary>
|
||||
public byte SubscriptionPriority { get; init; }
|
||||
|
||||
// ---- #277 ----
|
||||
|
||||
/// <summary>Certificate-revocation / issuer-store settings. Defaults preserve the SDK behaviour.</summary>
|
||||
public OpcUaCertificateValidationOptions CertificateValidation { get; init; } = new();
|
||||
|
||||
// ---- #278 ----
|
||||
|
||||
/// <summary>
|
||||
/// Optional discovery endpoint (a Local Discovery Server or the server itself), e.g.
|
||||
/// <c>opc.tcp://lds.internal:4840</c>. When set, <c>InitializeAsync</c> calls <c>FindServers</c> and
|
||||
/// <c>GetEndpoints</c> on it and tries the discovered URLs that match <see cref="SecurityPolicy"/> +
|
||||
/// <see cref="SecurityMode"/> <i>after</i> the statically configured <see cref="EndpointUrls"/> /
|
||||
/// <see cref="EndpointUrl"/> (pin the server with <see cref="DiscoveryServerUri"/>). A discovery failure is non-fatal: the static list is used. Discovery
|
||||
/// itself always runs over an unsecured channel, as the spec requires. <c>null</c> (default) = off.
|
||||
/// </summary>
|
||||
public string? DiscoveryUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ApplicationUri of the one server discovery may resolve to. An LDS usually registers several servers;
|
||||
/// with this set only that server's endpoints are used. When unset and discovery finds more than one
|
||||
/// server, connect fails with an error listing their ApplicationUris. Discovered endpoints are always
|
||||
/// tried AFTER the configured <see cref="EndpointUrls"/>.
|
||||
/// </summary>
|
||||
public string? DiscoveryServerUri { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-certificate revocation handling for the OPC UA Client driver (#277). The SDK reads CRLs from
|
||||
/// the <c>crl</c> sub-folder of the trusted-peer / trusted-issuer stores; these settings only decide how
|
||||
/// a certificate whose revocation status cannot be determined is treated and where the issuer store lives.
|
||||
/// </summary>
|
||||
public sealed record OpcUaCertificateValidationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// When <c>true</c>, a server certificate whose revocation status is unknown (no CRL for its issuer, or a
|
||||
/// stale one) is rejected. Default <c>false</c> keeps brownfield deployments without CRL infrastructure
|
||||
/// connecting. A certificate that IS revoked is always rejected — even with
|
||||
/// <see cref="OpcUaClientDriverOptions.AutoAcceptCertificates"/> set.
|
||||
/// </summary>
|
||||
public bool RejectUnknownRevocationStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Directory used as the trusted-issuer certificate store. Its <c>crl/</c> sub-folder is where the SDK
|
||||
/// looks for issuer CRLs (<c>.crl</c> files), so this is the knob for pointing the driver at an
|
||||
/// operator-managed CRL set. <c>null</c>/empty (default) = <c>%LocalAppData%/OtOpcUa/pki/issuers</c>.
|
||||
/// </summary>
|
||||
public string? IssuerStorePath { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
/// <summary>
|
||||
/// Publishes an OPC UA Client driver instance's session/publish events (#276) on the shared
|
||||
/// <c>otopcua.driver.opcuaclient.events</c> OTel counter, tagged with <c>driver_instance</c> and
|
||||
/// <c>event</c>. The counter is thread-safe, so the SDK's notification / keep-alive threads record
|
||||
/// directly with no locking here.
|
||||
/// </summary>
|
||||
internal sealed class OpcUaClientDiagnostics
|
||||
{
|
||||
/// <summary>A NotificationMessage arrived (one per Publish response that carried data).</summary>
|
||||
public const string PublishResponse = "publish_response";
|
||||
/// <summary>Items carried by notification messages.</summary>
|
||||
public const string Notification = "notification";
|
||||
/// <summary>One monitored-item data change was delivered.</summary>
|
||||
public const string DataChange = "data_change";
|
||||
/// <summary>A delivered data change carried a Bad status.</summary>
|
||||
public const string BadStatusValue = "bad_status_value";
|
||||
/// <summary>A Publish failed (missing publish requests, subscription errors).</summary>
|
||||
public const string PublishError = "publish_error";
|
||||
/// <summary>A keep-alive came back Bad.</summary>
|
||||
public const string KeepAliveMiss = "keepalive_miss";
|
||||
/// <summary>The reconnect handler was armed.</summary>
|
||||
public const string ReconnectAttempt = "reconnect_attempt";
|
||||
/// <summary>A reconnect landed a new session.</summary>
|
||||
public const string ReconnectSuccess = "reconnect_success";
|
||||
/// <summary>A reconnect handler exhausted its retry window.</summary>
|
||||
public const string ReconnectExhausted = "reconnect_exhausted";
|
||||
|
||||
private readonly string _driverInstanceId;
|
||||
|
||||
/// <summary>Creates the recorder for one driver instance.</summary>
|
||||
/// <param name="driverInstanceId">Value of the <c>driver_instance</c> tag.</param>
|
||||
public OpcUaClientDiagnostics(string driverInstanceId) => _driverInstanceId = driverInstanceId;
|
||||
|
||||
/// <summary>Add <paramref name="count"/> to the counter for <paramref name="evt"/>.</summary>
|
||||
/// <param name="evt">One of the event constants.</param>
|
||||
/// <param name="count">Amount to add (default 1).</param>
|
||||
public void Record(string evt, long count = 1)
|
||||
{
|
||||
if (count <= 0) return;
|
||||
OtOpcUaTelemetry.OpcUaClientEvents.Add(count,
|
||||
new KeyValuePair<string, object?>(OtOpcUaTelemetry.PollOverrunDriverInstanceTag, _driverInstanceId),
|
||||
new KeyValuePair<string, object?>("event", evt));
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
{
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_diag = new OpcUaClientDiagnostics(driverInstanceId);
|
||||
_logger = logger ?? NullLogger<OpcUaClientDriver>.Instance;
|
||||
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
|
||||
}
|
||||
@@ -144,7 +145,16 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
/// <param name="map">The namespace map to install, or <c>null</c> to clear it.</param>
|
||||
internal void SetNamespaceMapForTest(NamespaceMap? map) => _namespaceMap = map;
|
||||
|
||||
/// <summary>Test-only view of the diagnostics counters (#276).</summary>
|
||||
internal OpcUaClientDiagnostics DiagnosticsForTest => _diag;
|
||||
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
/// <summary>Per-instance event counters published as OTel metrics (#276).</summary>
|
||||
private readonly OpcUaClientDiagnostics _diag;
|
||||
|
||||
private NotificationEventHandler? _notificationHandler;
|
||||
private PublishErrorEventHandler? _publishErrorHandler;
|
||||
private bool _disposed;
|
||||
/// <summary>URL of the endpoint the driver actually connected to. Exposed via <see cref="HostName"/>.</summary>
|
||||
private string? _connectedEndpointUrl;
|
||||
@@ -212,9 +222,36 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
// §8 "Namespace Assignment" — a misconfigured remote fails draft validation here,
|
||||
// not as a runtime surprise.
|
||||
ValidateNamespaceKind(_options);
|
||||
ValidateTuning(_options);
|
||||
|
||||
var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
||||
var candidates = ResolveEndpointCandidates(_options);
|
||||
|
||||
// #278: when a DiscoveryUrl is configured, FindServers/GetEndpoints on it first and try the
|
||||
// discovered URLs ahead of the static list. Non-fatal — an LDS outage must not block init.
|
||||
IReadOnlyList<string> discovered = [];
|
||||
if (!string.IsNullOrWhiteSpace(_options.DiscoveryUrl))
|
||||
{
|
||||
IReadOnlyList<DiscoveredEndpoint> found = [];
|
||||
try
|
||||
{
|
||||
found = await DiscoverEndpointsAsync(
|
||||
appConfig, _options.DiscoveryUrl!, _options.SecurityPolicy, _options.SecurityMode,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"OpcUaClientDriver '{DriverInstanceId}': discovery against '{DiscoveryUrl}' failed; " +
|
||||
"using the configured endpoint list only.", _driverInstanceId, _options.DiscoveryUrl);
|
||||
}
|
||||
|
||||
// Selection is OUTSIDE the try on purpose: an ambiguous LDS must fail the connect with a
|
||||
// clear message, not be swallowed into "discovery unavailable".
|
||||
discovered = SelectDiscoveredUrls(found, _options.DiscoveryServerUri);
|
||||
}
|
||||
|
||||
var candidates = ResolveEndpointCandidates(_options, discovered);
|
||||
|
||||
// G-2b: resolve any secret:-prefixed Password / UserCertificatePassword through the
|
||||
// shared secret store ONCE, here in the async connect flow, just before the credentials
|
||||
@@ -266,6 +303,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
// via TransferSubscriptions so local MonitoredItem handles stay valid.
|
||||
_keepAliveHandler = OnKeepAlive;
|
||||
session.KeepAlive += _keepAliveHandler;
|
||||
AttachDiagnostics(session);
|
||||
|
||||
// Build the bidirectional namespace map from the freshly negotiated session's
|
||||
// NamespaceUris (driver-specs.md §8 "Namespace Remapping"). Stored NodeIds carry
|
||||
@@ -331,7 +369,11 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
TrustedIssuerCertificates = new CertificateTrustList
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(pkiRoot, "issuers"),
|
||||
// #277: the issuer store's crl/ sub-folder is where the SDK reads issuer CRLs, so an
|
||||
// operator-supplied path is the CRL knob. Unset = the historical default.
|
||||
StorePath = string.IsNullOrWhiteSpace(_options.CertificateValidation.IssuerStorePath)
|
||||
? Path.Combine(pkiRoot, "issuers")
|
||||
: _options.CertificateValidation.IssuerStorePath,
|
||||
},
|
||||
RejectedCertificateStore = new CertificateTrustList
|
||||
{
|
||||
@@ -339,6 +381,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
StorePath = Path.Combine(pkiRoot, "rejected"),
|
||||
},
|
||||
AutoAcceptUntrustedCertificates = _options.AutoAcceptCertificates,
|
||||
RejectUnknownRevocationStatus = _options.CertificateValidation.RejectUnknownRevocationStatus,
|
||||
},
|
||||
TransportQuotas = new TransportQuotas { OperationTimeout = (int)_options.Timeout.TotalMilliseconds },
|
||||
ClientConfiguration = new ClientConfiguration
|
||||
@@ -364,8 +407,22 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
// Accept the full set of certificate-validation error codes: a real dev cert can
|
||||
// fail with BadCertificateChainIncomplete, BadCertificateTimeInvalid, or
|
||||
// BadCertificateHostNameInvalid, not only BadCertificateUntrusted. Only accepting
|
||||
// the latter would silently fail for those certs.
|
||||
CertificateValidationEventHandler handler = (_, e) => e.Accept = true;
|
||||
// the latter would silently fail for those certs. A REVOKED certificate is the one
|
||||
// exception (#277): auto-accept is for dev self-signed certs, never for a cert its issuer
|
||||
// has explicitly withdrawn.
|
||||
CertificateValidationEventHandler handler = (_, e) =>
|
||||
{
|
||||
if (IsRevocationFailure(e.Error.StatusCode))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"OpcUaClientDriver '{DriverInstanceId}': REVOKED server certificate rejected " +
|
||||
"(subject={Subject}, status=0x{Status:X8}) — AutoAcceptCertificates does not apply to revoked certs.",
|
||||
_driverInstanceId, e.Certificate?.Subject, e.Error.StatusCode.Code);
|
||||
e.Accept = false;
|
||||
return;
|
||||
}
|
||||
e.Accept = true;
|
||||
};
|
||||
config.CertificateValidator.CertificateValidation += handler;
|
||||
// Store refs so ShutdownAsync + Dispose can detach the delegate and avoid
|
||||
// leaking a closure on a potentially process-shared validator.
|
||||
@@ -381,6 +438,12 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>True for the two hard "this certificate (or its issuer) is revoked" results (#277).</summary>
|
||||
/// <param name="status">The SDK's certificate-validation status.</param>
|
||||
/// <returns><c>true</c> when the status is Revoked / IssuerRevoked.</returns>
|
||||
internal static bool IsRevocationFailure(StatusCode status) =>
|
||||
status.Code == StatusCodes.BadCertificateRevoked || status.Code == StatusCodes.BadCertificateIssuerRevoked;
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the ordered failover candidate list. <c>EndpointUrls</c> wins when
|
||||
/// non-empty; otherwise fall back to <c>EndpointUrl</c> as a single-URL shortcut so
|
||||
@@ -388,10 +451,173 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
/// </summary>
|
||||
/// <param name="opts">Driver options containing endpoint configuration.</param>
|
||||
/// <returns>The ordered list of endpoint URLs to try.</returns>
|
||||
internal static IReadOnlyList<string> ResolveEndpointCandidates(OpcUaClientDriverOptions opts)
|
||||
internal static IReadOnlyList<string> ResolveEndpointCandidates(OpcUaClientDriverOptions opts) =>
|
||||
ResolveEndpointCandidates(opts, []);
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the ordered failover list with optional discovery results (#278). The configured
|
||||
/// endpoints always come FIRST — a discovered endpoint can only ever be a fallback, never outrank or
|
||||
/// replace the server the operator named. Discovered URLs follow, de-duplicated case-insensitively.
|
||||
/// With no discovered URLs the result is exactly the pre-discovery behaviour.
|
||||
/// </summary>
|
||||
/// <param name="opts">Driver options containing endpoint configuration.</param>
|
||||
/// <param name="discovered">URLs resolved via <see cref="DiscoverEndpointsAsync"/> (may be empty).</param>
|
||||
/// <returns>The ordered list of endpoint URLs to try.</returns>
|
||||
internal static IReadOnlyList<string> ResolveEndpointCandidates(
|
||||
OpcUaClientDriverOptions opts, IReadOnlyList<string> discovered)
|
||||
{
|
||||
if (opts.EndpointUrls is { Count: > 0 }) return opts.EndpointUrls;
|
||||
return [opts.EndpointUrl];
|
||||
IReadOnlyList<string> staticList = opts.EndpointUrls is { Count: > 0 } ? opts.EndpointUrls : [opts.EndpointUrl];
|
||||
if (discovered.Count == 0) return staticList;
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var merged = new List<string>(discovered.Count + staticList.Count);
|
||||
foreach (var u in staticList)
|
||||
if (!string.IsNullOrWhiteSpace(u) && seen.Add(u)) merged.Add(u);
|
||||
foreach (var u in discovered)
|
||||
if (!string.IsNullOrWhiteSpace(u) && seen.Add(u)) merged.Add(u);
|
||||
return merged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>FindServers</c> on <paramref name="discoveryUrl"/> (an LDS, or the server itself), then
|
||||
/// <c>GetEndpoints</c> on each returned server's discovery URL, keeping the endpoints whose security
|
||||
/// policy + mode match. One unreachable server in the list is skipped, not fatal. The discovery
|
||||
/// channel is unsecured by design (OPC UA Part 4 §5.4) regardless of the session's security mode.
|
||||
/// </summary>
|
||||
/// <param name="appConfig">Application configuration (client identity for the discovery channel).</param>
|
||||
/// <param name="discoveryUrl">The LDS / server discovery URL.</param>
|
||||
/// <param name="policy">Required security policy.</param>
|
||||
/// <param name="mode">Required security mode.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Matching endpoint URLs in discovery order, de-duplicated.</returns>
|
||||
internal static async Task<IReadOnlyList<DiscoveredEndpoint>> DiscoverEndpointsAsync(
|
||||
ApplicationConfiguration appConfig, string discoveryUrl,
|
||||
OpcUaSecurityPolicy policy, OpcUaSecurityMode mode, CancellationToken ct)
|
||||
{
|
||||
var wantedPolicyUri = MapSecurityPolicy(policy);
|
||||
var wantedMode = ToMessageSecurityMode(mode);
|
||||
|
||||
ApplicationDescriptionCollection servers;
|
||||
using (var lds = await DiscoveryClient.CreateAsync(
|
||||
appConfig, new Uri(discoveryUrl), Opc.Ua.DiagnosticsMasks.None, ct).ConfigureAwait(false))
|
||||
{
|
||||
servers = await lds.FindServersAsync(null, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var found = new List<DiscoveredEndpoint>();
|
||||
foreach (var server in servers)
|
||||
{
|
||||
if (server.DiscoveryUrls is null) continue;
|
||||
foreach (var serverDiscoveryUrl in server.DiscoveryUrls)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(serverDiscoveryUrl)) continue;
|
||||
EndpointDescriptionCollection endpoints;
|
||||
try
|
||||
{
|
||||
using var ep = await DiscoveryClient.CreateAsync(
|
||||
appConfig, new Uri(serverDiscoveryUrl), Opc.Ua.DiagnosticsMasks.None, ct).ConfigureAwait(false);
|
||||
endpoints = await ep.GetEndpointsAsync(null, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||
catch { continue; }
|
||||
|
||||
found.AddRange(FilterEndpointUrls(endpoints, wantedPolicyUri, wantedMode)
|
||||
.Select(u => new DiscoveredEndpoint(server.ApplicationUri ?? string.Empty, u)));
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/// <summary>One endpoint URL found by discovery, with the ApplicationUri of the server advertising it.</summary>
|
||||
/// <param name="ApplicationUri">The advertising server's ApplicationUri (empty when it published none).</param>
|
||||
/// <param name="Url">The endpoint URL.</param>
|
||||
internal readonly record struct DiscoveredEndpoint(string ApplicationUri, string Url);
|
||||
|
||||
/// <summary>
|
||||
/// Decide which discovered endpoints may be used (#278). An LDS can register many servers (identical
|
||||
/// line PLCs, a gateway plus a historian), and the first one that accepts a connection would be bound
|
||||
/// silently — mis-attributing every value and write. So: with <paramref name="serverUri"/> set, only
|
||||
/// that ApplicationUri's endpoints; unset and exactly one distinct server, that server; unset and
|
||||
/// several, throw naming them so the operator pins one.
|
||||
/// </summary>
|
||||
/// <param name="found">Matching endpoints from every server the LDS listed.</param>
|
||||
/// <param name="serverUri">Optional <c>DiscoveryServerUri</c> filter.</param>
|
||||
/// <returns>The usable URLs, de-duplicated, in discovery order (possibly empty).</returns>
|
||||
/// <exception cref="InvalidOperationException">Several servers discovered and no filter configured.</exception>
|
||||
internal static IReadOnlyList<string> SelectDiscoveredUrls(
|
||||
IReadOnlyList<DiscoveredEndpoint> found, string? serverUri)
|
||||
{
|
||||
IEnumerable<DiscoveredEndpoint> chosen = found;
|
||||
if (!string.IsNullOrWhiteSpace(serverUri))
|
||||
{
|
||||
chosen = found.Where(e => string.Equals(e.ApplicationUri, serverUri, StringComparison.Ordinal));
|
||||
}
|
||||
else
|
||||
{
|
||||
var servers = found.Select(e => e.ApplicationUri).Distinct(StringComparer.Ordinal).ToList();
|
||||
if (servers.Count > 1)
|
||||
throw new InvalidOperationException(
|
||||
"OpcUaClient discovery found endpoints for more than one server " +
|
||||
$"({string.Join(", ", servers.Select(u => string.IsNullOrEmpty(u) ? "<no ApplicationUri>" : u))}). " +
|
||||
"Set DiscoveryServerUri to the ApplicationUri of the server this driver should use.");
|
||||
}
|
||||
return chosen.Select(e => e.Url).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
/// <summary>The endpoint URLs in <paramref name="endpoints"/> matching the wanted policy URI + mode.</summary>
|
||||
/// <param name="endpoints">Endpoints advertised by a server.</param>
|
||||
/// <param name="wantedPolicyUri">Required <c>SecurityPolicyUri</c>.</param>
|
||||
/// <param name="wantedMode">Required message security mode.</param>
|
||||
/// <returns>Matching <c>EndpointUrl</c>s, in advertised order.</returns>
|
||||
internal static IEnumerable<string> FilterEndpointUrls(
|
||||
IEnumerable<EndpointDescription> endpoints, string wantedPolicyUri, MessageSecurityMode wantedMode) =>
|
||||
endpoints
|
||||
.Where(e => e.SecurityPolicyUri == wantedPolicyUri && e.SecurityMode == wantedMode
|
||||
&& !string.IsNullOrWhiteSpace(e.EndpointUrl))
|
||||
.Select(e => e.EndpointUrl);
|
||||
|
||||
private static MessageSecurityMode ToMessageSecurityMode(OpcUaSecurityMode mode) => mode switch
|
||||
{
|
||||
OpcUaSecurityMode.None => MessageSecurityMode.None,
|
||||
OpcUaSecurityMode.Sign => MessageSecurityMode.Sign,
|
||||
OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode)),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Range checks for the subscription-tuning and discovery options (#273/#278). Throws at
|
||||
/// <see cref="InitializeAsync"/> so a bad config fails like the namespace-kind check does rather
|
||||
/// than surfacing later as a server-side <c>BadInvalidArgument</c> on every subscribe.
|
||||
/// </summary>
|
||||
/// <param name="opts">The options to validate.</param>
|
||||
internal static void ValidateTuning(OpcUaClientDriverOptions opts)
|
||||
{
|
||||
if (opts.SubscriptionKeepAliveCount < 1)
|
||||
throw new InvalidOperationException("OpcUaClient SubscriptionKeepAliveCount must be >= 1.");
|
||||
if (opts.SubscriptionLifetimeCount < 3L * opts.SubscriptionKeepAliveCount)
|
||||
throw new InvalidOperationException(
|
||||
"OpcUaClient SubscriptionLifetimeCount must be at least 3x SubscriptionKeepAliveCount " +
|
||||
$"(got lifetime={opts.SubscriptionLifetimeCount}, keep-alive={opts.SubscriptionKeepAliveCount}).");
|
||||
if (opts.SubscriptionPublishingInterval is { } pi && pi <= TimeSpan.Zero)
|
||||
throw new InvalidOperationException("OpcUaClient SubscriptionPublishingInterval must be positive when set.");
|
||||
if (!string.IsNullOrWhiteSpace(opts.DiscoveryUrl)
|
||||
&& !Uri.TryCreate(opts.DiscoveryUrl, UriKind.Absolute, out _))
|
||||
throw new InvalidOperationException(
|
||||
$"OpcUaClient DiscoveryUrl '{opts.DiscoveryUrl}' is not an absolute URI.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishing interval (ms) a new subscription gets: the configured override if set, else the
|
||||
/// interval the runtime requested, floored at 50 ms either way (servers negotiate up anyway, and
|
||||
/// sub-50 ms just wastes negotiation).
|
||||
/// </summary>
|
||||
/// <param name="opts">Driver options.</param>
|
||||
/// <param name="requested">The per-call interval from <c>SubscribeAsync</c>.</param>
|
||||
/// <returns>The publishing interval in milliseconds.</returns>
|
||||
internal static int ResolvePublishingIntervalMs(OpcUaClientDriverOptions opts, TimeSpan requested)
|
||||
{
|
||||
var effective = opts.SubscriptionPublishingInterval ?? requested;
|
||||
return effective < TimeSpan.FromMilliseconds(50) ? 50 : (int)effective.TotalMilliseconds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -510,13 +736,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
var all = await client.GetEndpointsAsync(null, ct).ConfigureAwait(false);
|
||||
|
||||
var wantedPolicyUri = MapSecurityPolicy(policy);
|
||||
var wantedMode = mode switch
|
||||
{
|
||||
OpcUaSecurityMode.None => MessageSecurityMode.None,
|
||||
OpcUaSecurityMode.Sign => MessageSecurityMode.Sign,
|
||||
OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mode)),
|
||||
};
|
||||
var wantedMode = ToMessageSecurityMode(mode);
|
||||
|
||||
var match = all.FirstOrDefault(e =>
|
||||
e.SecurityPolicyUri == wantedPolicyUri && e.SecurityMode == wantedMode);
|
||||
@@ -625,6 +845,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
try { sessionToClose.KeepAlive -= _keepAliveHandler; } catch { }
|
||||
}
|
||||
_keepAliveHandler = null;
|
||||
if (sessionToClose is not null) DetachDiagnostics(sessionToClose);
|
||||
Session = null;
|
||||
}
|
||||
|
||||
@@ -1245,20 +1466,18 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
|
||||
// Floor the publishing interval at 50ms — OPC UA servers routinely negotiate
|
||||
// minimum-supported intervals up anyway, but sending sub-50ms wastes negotiation
|
||||
// bandwidth on every subscription create.
|
||||
var intervalMs = publishingInterval < TimeSpan.FromMilliseconds(50)
|
||||
? 50
|
||||
: (int)publishingInterval.TotalMilliseconds;
|
||||
// bandwidth on every subscription create. #273: the options may pin the interval.
|
||||
var intervalMs = ResolvePublishingIntervalMs(_options, publishingInterval);
|
||||
|
||||
var subscription = new Subscription(telemetry: null!, new SubscriptionOptions
|
||||
{
|
||||
DisplayName = $"opcua-sub-{id}",
|
||||
PublishingInterval = intervalMs,
|
||||
KeepAliveCount = 10,
|
||||
LifetimeCount = 1000,
|
||||
MaxNotificationsPerPublish = 0,
|
||||
KeepAliveCount = _options.SubscriptionKeepAliveCount,
|
||||
LifetimeCount = _options.SubscriptionLifetimeCount,
|
||||
MaxNotificationsPerPublish = _options.SubscriptionMaxNotificationsPerPublish,
|
||||
PublishingEnabled = true,
|
||||
Priority = 0,
|
||||
Priority = _options.SubscriptionPriority,
|
||||
TimestampsToReturn = TimestampsToReturn.Both,
|
||||
});
|
||||
|
||||
@@ -1351,6 +1570,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
var dv = mn.Value;
|
||||
if (dv is null) return;
|
||||
var fullRef = (item.Handle as string) ?? item.DisplayName ?? string.Empty;
|
||||
_diag.Record(OpcUaClientDiagnostics.DataChange);
|
||||
if (StatusCode.IsBad(dv.StatusCode)) _diag.Record(OpcUaClientDiagnostics.BadStatusValue);
|
||||
var snapshot = new DataValueSnapshot(
|
||||
Value: dv.Value,
|
||||
StatusCode: dv.StatusCode.Code,
|
||||
@@ -1420,9 +1641,9 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
{
|
||||
DisplayName = $"opcua-alarm-sub-{id}",
|
||||
PublishingInterval = 500, // 500ms — alarms don't need fast polling; the server pushes
|
||||
KeepAliveCount = 10,
|
||||
LifetimeCount = 1000,
|
||||
MaxNotificationsPerPublish = 0,
|
||||
KeepAliveCount = _options.SubscriptionKeepAliveCount,
|
||||
LifetimeCount = _options.SubscriptionLifetimeCount,
|
||||
MaxNotificationsPerPublish = _options.SubscriptionMaxNotificationsPerPublish,
|
||||
PublishingEnabled = true,
|
||||
Priority = 0,
|
||||
TimestampsToReturn = TimestampsToReturn.Both,
|
||||
@@ -1794,6 +2015,26 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
HistoryAggregateType.Maximum => ObjectIds.AggregateFunction_Maximum,
|
||||
HistoryAggregateType.Total => ObjectIds.AggregateFunction_Total,
|
||||
HistoryAggregateType.Count => ObjectIds.AggregateFunction_Count,
|
||||
HistoryAggregateType.Interpolative => ObjectIds.AggregateFunction_Interpolative,
|
||||
HistoryAggregateType.TimeAverage => ObjectIds.AggregateFunction_TimeAverage,
|
||||
HistoryAggregateType.Start => ObjectIds.AggregateFunction_Start,
|
||||
HistoryAggregateType.End => ObjectIds.AggregateFunction_End,
|
||||
HistoryAggregateType.Delta => ObjectIds.AggregateFunction_Delta,
|
||||
HistoryAggregateType.Range => ObjectIds.AggregateFunction_Range,
|
||||
HistoryAggregateType.MinimumActualTime => ObjectIds.AggregateFunction_MinimumActualTime,
|
||||
HistoryAggregateType.MaximumActualTime => ObjectIds.AggregateFunction_MaximumActualTime,
|
||||
HistoryAggregateType.StartBound => ObjectIds.AggregateFunction_StartBound,
|
||||
HistoryAggregateType.EndBound => ObjectIds.AggregateFunction_EndBound,
|
||||
HistoryAggregateType.DeltaBounds => ObjectIds.AggregateFunction_DeltaBounds,
|
||||
HistoryAggregateType.DurationGood => ObjectIds.AggregateFunction_DurationGood,
|
||||
HistoryAggregateType.DurationBad => ObjectIds.AggregateFunction_DurationBad,
|
||||
HistoryAggregateType.PercentGood => ObjectIds.AggregateFunction_PercentGood,
|
||||
HistoryAggregateType.PercentBad => ObjectIds.AggregateFunction_PercentBad,
|
||||
HistoryAggregateType.WorstQuality => ObjectIds.AggregateFunction_WorstQuality,
|
||||
HistoryAggregateType.NumberOfTransitions => ObjectIds.AggregateFunction_NumberOfTransitions,
|
||||
HistoryAggregateType.AnnotationCount => ObjectIds.AggregateFunction_AnnotationCount,
|
||||
HistoryAggregateType.StandardDeviationPopulation => ObjectIds.AggregateFunction_StandardDeviationPopulation,
|
||||
HistoryAggregateType.VariancePopulation => ObjectIds.AggregateFunction_VariancePopulation,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, null),
|
||||
};
|
||||
|
||||
@@ -1983,6 +2224,43 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
|
||||
}
|
||||
|
||||
/// <summary>Hook the SDK's publish-side events into the diagnostics counters (#276).</summary>
|
||||
/// <param name="session">The session to observe.</param>
|
||||
private void AttachDiagnostics(ISession session)
|
||||
{
|
||||
_notificationHandler ??= (_, e) =>
|
||||
{
|
||||
_diag.Record(OpcUaClientDiagnostics.PublishResponse);
|
||||
var msg = e.NotificationMessage;
|
||||
if (msg?.NotificationData is { } data)
|
||||
foreach (var nd in data)
|
||||
_diag.Record(OpcUaClientDiagnostics.Notification, CountNotifications(nd));
|
||||
};
|
||||
_publishErrorHandler ??= (_, _) => _diag.Record(OpcUaClientDiagnostics.PublishError);
|
||||
session.Notification -= _notificationHandler;
|
||||
session.Notification += _notificationHandler;
|
||||
session.PublishError -= _publishErrorHandler;
|
||||
session.PublishError += _publishErrorHandler;
|
||||
}
|
||||
|
||||
private void DetachDiagnostics(ISession session)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_notificationHandler is not null) session.Notification -= _notificationHandler;
|
||||
if (_publishErrorHandler is not null) session.PublishError -= _publishErrorHandler;
|
||||
}
|
||||
catch { /* best-effort — SDK may have already cleared its invocation lists on session loss */ }
|
||||
}
|
||||
|
||||
private static int CountNotifications(ExtensionObject nd) => nd.Body switch
|
||||
{
|
||||
DataChangeNotification dc => dc.MonitoredItems?.Count ?? 0,
|
||||
EventNotificationList ev => ev.Events?.Count ?? 0,
|
||||
StatusChangeNotification => 1,
|
||||
_ => 1,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Session keep-alive handler. On a healthy ping, bumps HostState back to Running
|
||||
/// (typical bounce after a transient network blip). On a bad ping, starts the SDK's
|
||||
@@ -1998,6 +2276,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
}
|
||||
|
||||
TransitionTo(HostState.Stopped);
|
||||
_diag.Record(OpcUaClientDiagnostics.KeepAliveMiss);
|
||||
|
||||
// Kick off the SDK's reconnect loop exactly once per drop. Keep-alive callbacks fire
|
||||
// from the SDK keep-alive timer thread and the SDK can fire this handler repeatedly
|
||||
@@ -2017,6 +2296,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
|
||||
// BeginReconnect is started outside the lock — it does no _reconnectHandler mutation
|
||||
// and we don't want to hold _probeLock across an SDK call.
|
||||
_diag.Record(OpcUaClientDiagnostics.ReconnectAttempt);
|
||||
handler.BeginReconnect(
|
||||
sender,
|
||||
(int)_options.ReconnectPeriod.TotalMilliseconds,
|
||||
@@ -2057,6 +2337,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
{
|
||||
newSession.KeepAlive += _keepAliveHandler;
|
||||
}
|
||||
// Detach-then-attach: the SDK may hand back the SAME session object, and -= on a handler that
|
||||
// is not attached is a no-op, so this never double-counts.
|
||||
if (oldSession is not null) DetachDiagnostics(oldSession);
|
||||
if (newSession is not null) AttachDiagnostics(newSession);
|
||||
|
||||
Session = newSession;
|
||||
|
||||
@@ -2079,6 +2363,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
RecordSkippedTags(_namespaceMap);
|
||||
TransitionTo(HostState.Running);
|
||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
_diag.Record(OpcUaClientDiagnostics.ReconnectSuccess);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2091,6 +2376,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
// the last-known session for an always-on gateway rather than abandoning recovery
|
||||
// entirely.
|
||||
TransitionTo(HostState.Faulted);
|
||||
_diag.Record(OpcUaClientDiagnostics.ReconnectExhausted);
|
||||
_health = new DriverHealth(
|
||||
DriverState.Faulted, _health.LastSuccessfulRead,
|
||||
"OPC UA session reconnect exhausted its retry window without recovering. " +
|
||||
|
||||
+11
-1
@@ -86,8 +86,18 @@ public static class OpcUaClientDriverFactoryExtensions
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
|
||||
return JsonSerializer.Deserialize<OpcUaClientDriverOptions>(driverConfigJson, JsonOptions)
|
||||
var options = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(driverConfigJson, JsonOptions)
|
||||
?? throw new InvalidOperationException(
|
||||
$"OpcUaClient driver config for '{driverInstanceId}' deserialised to null");
|
||||
|
||||
// #273/#278: nothing runs DataAnnotations on driver options, so range-check here — at options load —
|
||||
// and fail with a clear message instead of a driver that faults and retries forever.
|
||||
try { OpcUaClientDriver.ValidateTuning(options); }
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"OpcUaClient driver config for '{driverInstanceId}' is invalid: {ex.Message}", ex);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
+89
@@ -185,6 +185,61 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Subscriptions, limits, revocation, discovery (#273/#275/#277/#278) *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.13s">
|
||||
<div class="panel-head">Subscriptions, limits & discovery</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Publishing interval override (ms)</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.SubscriptionPublishingIntervalMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Blank = use the interval the runtime requests per subscription.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Keep-alive count</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.SubscriptionKeepAliveCount" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 10.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Lifetime count</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.SubscriptionLifetimeCount" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default 1000; at least 3x keep-alive count.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max notifications per publish</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.SubscriptionMaxNotificationsPerPublish" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">0 = server decides.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Subscription priority (0-255)</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.SubscriptionPriority" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.OpcUa.RejectUnknownRevocationStatus" @bind-Value:after="EmitAsync" class="form-check-input" id="rejectUnkRevocation" />
|
||||
<label class="form-check-label" for="rejectUnkRevocation">Reject unknown revocation status</label>
|
||||
</div>
|
||||
<div class="form-text">Revoked certificates are always rejected.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Issuer store / CRL directory</label>
|
||||
<InputText @bind-Value="_form.OpcUa.IssuerStorePath" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" />
|
||||
<div class="form-text">CRLs are read from its <code>crl/</code> sub-folder. Blank = default PKI store.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Discovery URL (FindServers)</label>
|
||||
<InputText @bind-Value="_form.OpcUa.DiscoveryUrl" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="opc.tcp://lds:4840" />
|
||||
<div class="form-text">Optional. Discovered endpoints are tried after the failover list.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Discovery server ApplicationUri</label>
|
||||
<InputText @bind-Value="_form.OpcUa.DiscoveryServerUri" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" />
|
||||
<div class="form-text">Required when the discovery server lists more than one server.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Diagnostics *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.14s">
|
||||
<div class="panel-head">Diagnostics</div>
|
||||
@@ -318,6 +373,18 @@
|
||||
|
||||
public int ProbeTimeoutSeconds { get; set; } = 15;
|
||||
|
||||
// #273 / #275 / #277 / #278 — without these the typed form silently drops the keys on save (#576).
|
||||
public int? SubscriptionPublishingIntervalMs { get; set; }
|
||||
// int, not uint/byte: InputNumber<byte> 500s the page (#505).
|
||||
public int SubscriptionKeepAliveCount { get; set; } = 10;
|
||||
public int SubscriptionLifetimeCount { get; set; } = 1000;
|
||||
public int SubscriptionMaxNotificationsPerPublish { get; set; }
|
||||
public int SubscriptionPriority { get; set; }
|
||||
public bool RejectUnknownRevocationStatus { get; set; }
|
||||
public string? IssuerStorePath { get; set; }
|
||||
public string? DiscoveryUrl { get; set; }
|
||||
public string? DiscoveryServerUri { get; set; }
|
||||
|
||||
internal IReadOnlyDictionary<string, string> _unsMappingTable = new Dictionary<string, string>();
|
||||
|
||||
public static OpcUaClientFormModel FromRecord(OpcUaClientDriverOptions r) => new()
|
||||
@@ -343,6 +410,15 @@
|
||||
UserCertificatePassword = r.UserCertificatePassword,
|
||||
TargetNamespaceKind = r.TargetNamespaceKind,
|
||||
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
|
||||
SubscriptionPublishingIntervalMs = r.SubscriptionPublishingInterval is { } spi ? (int)spi.TotalMilliseconds : null,
|
||||
SubscriptionKeepAliveCount = (int)Math.Min(r.SubscriptionKeepAliveCount, int.MaxValue),
|
||||
SubscriptionLifetimeCount = (int)Math.Min(r.SubscriptionLifetimeCount, int.MaxValue),
|
||||
SubscriptionMaxNotificationsPerPublish = (int)Math.Min(r.SubscriptionMaxNotificationsPerPublish, int.MaxValue),
|
||||
SubscriptionPriority = r.SubscriptionPriority,
|
||||
RejectUnknownRevocationStatus = r.CertificateValidation.RejectUnknownRevocationStatus,
|
||||
IssuerStorePath = r.CertificateValidation.IssuerStorePath,
|
||||
DiscoveryUrl = r.DiscoveryUrl,
|
||||
DiscoveryServerUri = r.DiscoveryServerUri,
|
||||
_unsMappingTable = r.UnsMappingTable,
|
||||
};
|
||||
|
||||
@@ -371,6 +447,19 @@
|
||||
TargetNamespaceKind = TargetNamespaceKind,
|
||||
UnsMappingTable = _unsMappingTable,
|
||||
ProbeTimeoutSeconds = ProbeTimeoutSeconds,
|
||||
SubscriptionPublishingInterval = SubscriptionPublishingIntervalMs is { } ms and > 0
|
||||
? TimeSpan.FromMilliseconds(ms) : null,
|
||||
SubscriptionKeepAliveCount = (uint)Math.Max(SubscriptionKeepAliveCount, 0),
|
||||
SubscriptionLifetimeCount = (uint)Math.Max(SubscriptionLifetimeCount, 0),
|
||||
SubscriptionMaxNotificationsPerPublish = (uint)Math.Max(SubscriptionMaxNotificationsPerPublish, 0),
|
||||
SubscriptionPriority = (byte)Math.Clamp(SubscriptionPriority, 0, 255),
|
||||
CertificateValidation = new OpcUaCertificateValidationOptions
|
||||
{
|
||||
RejectUnknownRevocationStatus = RejectUnknownRevocationStatus,
|
||||
IssuerStorePath = string.IsNullOrWhiteSpace(IssuerStorePath) ? null : IssuerStorePath,
|
||||
},
|
||||
DiscoveryUrl = string.IsNullOrWhiteSpace(DiscoveryUrl) ? null : DiscoveryUrl,
|
||||
DiscoveryServerUri = string.IsNullOrWhiteSpace(DiscoveryServerUri) ? null : DiscoveryServerUri,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1243,7 +1243,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// path; keep it symmetric with RemoveAlarmConditionNode / RebuildAddressSpace.
|
||||
UnwireAlarmNotifiers(conditionKey);
|
||||
existing.Parent?.RemoveChild(existing);
|
||||
PredefinedNodes?.Remove(existing.NodeId);
|
||||
ForgetPredefinedSubtree(existing);
|
||||
// The materialise tail raises only the new instance's unit (no full reconcile), so give the
|
||||
// dropped instance's back here rather than leave it tracked until the next full reconcile.
|
||||
if (_allEventsMonitoredConditions.Remove(existing))
|
||||
@@ -2499,6 +2499,89 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
_ => new AlarmConditionState(parent),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #629 — the <c>AlarmConditionType_ShelvingState_*</c> method instance declarations a client calls
|
||||
/// the shelve methods by. <see cref="New"/> records them as the methods' <c>MethodDeclarationId</c>
|
||||
/// (the SDK's own is the <c>ShelvedStateMachineType</c>'s), so calls by these ids keep resolving after
|
||||
/// the methods move to instance-scoped NodeIds. Calls by the <c>ShelvedStateMachineType_*</c> ids are
|
||||
/// not supported (pinned by <c>ConditionChildNodeIdTests</c>).
|
||||
/// </summary>
|
||||
private static readonly HashSet<NodeId> ShelvingMethodDeclarationIds =
|
||||
[
|
||||
MethodIds.AlarmConditionType_ShelvingState_TimedShelve,
|
||||
MethodIds.AlarmConditionType_ShelvingState_OneShotShelve,
|
||||
MethodIds.AlarmConditionType_ShelvingState_Unshelve,
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #629 — the <see cref="INodeIdFactory"/> hook <c>NodeState.Create(…, assignNodeIds: true)</c> calls
|
||||
/// for the node being created and then, top-down, for every child it built from the type's definition.
|
||||
/// A node that already carries an id in one of our namespaces (the condition root, whose id
|
||||
/// <see cref="MaterialiseAlarmCondition"/> passes to <c>Create</c>) keeps it; a child is given
|
||||
/// <c>{parent id}/{BrowseName}</c> in its parent's namespace.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The SDK default (<c>CustomNodeManager2.New</c>) returns the node's CURRENT id, which for a child is
|
||||
/// the condition type's instance-declaration id (<c>i=9189</c> for <c>ShelvingState/UnshelveTime</c>) or
|
||||
/// an SDK-generated ns=0 number. Every condition's children therefore sat in ns=0: a
|
||||
/// TranslateBrowsePathsToNodeIds of any child path returned <c>BadNoMatch</c>, and a Read of a browsed
|
||||
/// child id either hit <c>BadNodeIdUnknown</c> or was served by the core node manager from the TYPE
|
||||
/// (UnshelveTime read <c>0</c> on a condition timed-shelved for a minute). Event delivery and the Part 9
|
||||
/// methods were unaffected — events copy the instance's fields and the SDK resolves a method by its
|
||||
/// declaration id.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The derived id is deterministic and unique per condition: the parent part is the condition's own
|
||||
/// id (a RawPath in the raw namespace, a ScriptedAlarmId in the UNS namespace) and sibling BrowseNames
|
||||
/// are unique. It cannot collide with a raw node — a condition's RawPath is a tag, a leaf, so nothing
|
||||
/// else is ever authored beneath it — and the namespace keeps the two realms apart. It is also
|
||||
/// slash-joined on purpose: <c>DataPlaneAuthorizer.BuildScope</c> splits the identifier on <c>/</c>,
|
||||
/// so a child's scope is its condition's scope plus the browse path, and the permission trie's
|
||||
/// downward inheritance governs a child exactly as it governs its condition.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="context">The system context of the create.</param>
|
||||
/// <param name="node">The node being assigned an id.</param>
|
||||
/// <returns>The id the node is to carry.</returns>
|
||||
public override NodeId New(ISystemContext context, NodeState node)
|
||||
{
|
||||
if (OwnsNamespace(node.NodeId)) return node.NodeId;
|
||||
if (node is BaseInstanceState { Parent.NodeId: { IdType: IdType.String } parentId }
|
||||
&& OwnsNamespace(parentId)
|
||||
&& !QualifiedName.IsNull(node.BrowseName))
|
||||
{
|
||||
// A client calls a Part 9 method by its TYPE's method id (ObjectId = the condition, MethodId =
|
||||
// e.g. AlarmConditionType_ShelvingState_OneShotShelve, i=9212), and the SDK resolves it by NodeId OR
|
||||
// MethodDeclarationId. The condition's own methods (Acknowledge, …) carry the right declaration id
|
||||
// already, over an SDK-generated ns=0 NodeId. The ShelvingState methods do NOT: their declaration id
|
||||
// is the ShelvedStateMachineType's (i=2948), and they were found only because their NodeId WAS the
|
||||
// AlarmConditionType instance declaration every client uses. Adopt that id as the declaration before
|
||||
// it is replaced, or every shelve/unshelve call fails BadMethodInvalid. A fixed set, deliberately: a
|
||||
// lookup in the standard address space would take other node managers' locks while we hold Lock.
|
||||
if (node is MethodState method && ShelvingMethodDeclarationIds.Contains(method.NodeId))
|
||||
method.MethodDeclarationId = method.NodeId;
|
||||
|
||||
return new NodeId($"{parentId.Identifier}/{node.BrowseName.Name}", parentId.NamespaceIndex);
|
||||
}
|
||||
return node.NodeId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #629 — drops a removed condition AND every descendant from <c>PredefinedNodes</c>.
|
||||
/// <c>AddPredefinedNode</c> indexes the whole subtree, so removing only the root would leave the
|
||||
/// condition's (now instance-scoped, hence reachable) children answering a direct Read with the dead
|
||||
/// condition's last value. MUST be called under <c>Lock</c>.
|
||||
/// </summary>
|
||||
/// <param name="node">The node whose subtree is forgotten.</param>
|
||||
private void ForgetPredefinedSubtree(NodeState node)
|
||||
{
|
||||
PredefinedNodes?.Remove(node.NodeId);
|
||||
var children = new List<BaseInstanceState>();
|
||||
node.GetChildren(SystemContext, children);
|
||||
foreach (var child in children) ForgetPredefinedSubtree(child);
|
||||
}
|
||||
|
||||
/// <summary>Promote <paramref name="folder"/> to <see cref="EventNotifiers.SubscribeToEvents"/> and
|
||||
/// register it as a root notifier (idempotent — guarded by <see cref="_notifierFolders"/>) so the
|
||||
/// folder is a subscribable event source linked under the Server object (Server-object delivery itself is
|
||||
@@ -3123,7 +3206,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
foreach (var alarm in _alarmConditions.Values)
|
||||
{
|
||||
alarm.Parent?.RemoveChild(alarm);
|
||||
PredefinedNodes?.Remove(alarm.NodeId);
|
||||
ForgetPredefinedSubtree(alarm);
|
||||
}
|
||||
// v3 Batch 4 WP4: tear down every native-alarm→equipment-folder notifier pair bidirectionally
|
||||
// BEFORE the conditions + folders are cleared below, so no inverse-notifier entry leaks across the
|
||||
@@ -3249,7 +3332,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// this they would keep a dangling inverse-notifier entry to the removed condition.
|
||||
UnwireAlarmNotifiers(key);
|
||||
condition.Parent?.RemoveChild(condition);
|
||||
PredefinedNodes?.Remove(condition.NodeId);
|
||||
ForgetPredefinedSubtree(condition);
|
||||
_nativeAlarmNodeIds.Remove(key);
|
||||
ReconcileAllEventsMonitoring();
|
||||
e = BuildNodesRemovedModelChange(alarmNodeId, realm);
|
||||
@@ -3302,7 +3385,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
if (_alarmConditions.TryRemove(id, out var c))
|
||||
{
|
||||
c.Parent?.RemoveChild(c);
|
||||
PredefinedNodes?.Remove(c.NodeId);
|
||||
ForgetPredefinedSubtree(c);
|
||||
}
|
||||
_nativeAlarmNodeIds.Remove(id);
|
||||
}
|
||||
@@ -5169,6 +5252,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
errors[handle.Index] = StatusCodes.BadTimeout;
|
||||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
|
||||
}
|
||||
catch (HistoryAggregateNotSupportedException ex)
|
||||
{
|
||||
// The backend cannot serve this Part 13 aggregate (#285) — the spec status for THIS node,
|
||||
// not a logged server fault.
|
||||
_logger.LogDebug(ex, "OtOpcUaNodeManager: backend does not support aggregate for node {NodeId}", handle.NodeId);
|
||||
errors[handle.Index] = StatusCodes.BadAggregateNotSupported;
|
||||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadAggregateNotSupported };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// One node's backend failure (throw / timeout) must not throw out of the batch — surface a
|
||||
@@ -5424,6 +5515,26 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_Maximum) return HistoryAggregateType.Maximum;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_Total) return HistoryAggregateType.Total;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_Count) return HistoryAggregateType.Count;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_Interpolative) return HistoryAggregateType.Interpolative;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_TimeAverage) return HistoryAggregateType.TimeAverage;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_Start) return HistoryAggregateType.Start;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_End) return HistoryAggregateType.End;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_Delta) return HistoryAggregateType.Delta;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_Range) return HistoryAggregateType.Range;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_MinimumActualTime) return HistoryAggregateType.MinimumActualTime;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_MaximumActualTime) return HistoryAggregateType.MaximumActualTime;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_StartBound) return HistoryAggregateType.StartBound;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_EndBound) return HistoryAggregateType.EndBound;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_DeltaBounds) return HistoryAggregateType.DeltaBounds;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_DurationGood) return HistoryAggregateType.DurationGood;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_DurationBad) return HistoryAggregateType.DurationBad;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_PercentGood) return HistoryAggregateType.PercentGood;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_PercentBad) return HistoryAggregateType.PercentBad;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_WorstQuality) return HistoryAggregateType.WorstQuality;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_NumberOfTransitions) return HistoryAggregateType.NumberOfTransitions;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_AnnotationCount) return HistoryAggregateType.AnnotationCount;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_StandardDeviationPopulation) return HistoryAggregateType.StandardDeviationPopulation;
|
||||
if (aggregateNodeId == ObjectIds.AggregateFunction_VariancePopulation) return HistoryAggregateType.VariancePopulation;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+17
-3
@@ -16,10 +16,24 @@ public sealed class AggregateModeMapperTests
|
||||
public void Maps_each_aggregate(HistoryAggregateType a, RetrievalMode expected)
|
||||
=> Assert.Equal(expected, AggregateModeMapper.ToRetrievalMode(a));
|
||||
|
||||
[Fact] // matrix guard: a new HistoryAggregateType member must fail here
|
||||
public void Every_aggregate_member_is_mapped()
|
||||
[Fact] // matrix guard: every member is either mapped or explicitly reported as not supported
|
||||
public void Every_aggregate_member_is_mapped_or_reports_not_supported()
|
||||
{
|
||||
var original = new[]
|
||||
{
|
||||
HistoryAggregateType.Average, HistoryAggregateType.Minimum, HistoryAggregateType.Maximum,
|
||||
HistoryAggregateType.Total, HistoryAggregateType.Count,
|
||||
};
|
||||
foreach (var a in Enum.GetValues<HistoryAggregateType>())
|
||||
_ = AggregateModeMapper.ToRetrievalMode(a); // must not throw for any defined member
|
||||
{
|
||||
if (original.Contains(a))
|
||||
_ = AggregateModeMapper.ToRetrievalMode(a);
|
||||
else
|
||||
Assert.Throws<HistoryAggregateNotSupportedException>(() => AggregateModeMapper.ToRetrievalMode(a));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Undefined_value_is_a_programming_error_not_a_not_supported()
|
||||
=> Assert.Throws<ArgumentOutOfRangeException>(() => AggregateModeMapper.ToRetrievalMode((HistoryAggregateType)9999));
|
||||
}
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// #276 — session/publish events are published on the <c>otopcua.driver.opcuaclient.events</c> OTel counter.
|
||||
/// The listener filters on a UNIQUE <c>driver_instance</c> tag: the Meter is process-static, so a
|
||||
/// name-only filter would also collect parallel tests' measurements.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientDiagnosticsTests
|
||||
{
|
||||
private sealed class Capture : IDisposable
|
||||
{
|
||||
private readonly MeterListener _listener = new();
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, long> _byEvent = new();
|
||||
|
||||
public Capture(string instance)
|
||||
{
|
||||
_listener.InstrumentPublished = (inst, l) =>
|
||||
{
|
||||
if (inst.Name == "otopcua.driver.opcuaclient.events") l.EnableMeasurementEvents(inst);
|
||||
};
|
||||
_listener.SetMeasurementEventCallback<long>((_, value, tags, _) =>
|
||||
{
|
||||
string? driver = null, evt = null;
|
||||
foreach (var t in tags)
|
||||
{
|
||||
if (t.Key == "driver_instance") driver = t.Value as string;
|
||||
else if (t.Key == "event") evt = t.Value as string;
|
||||
}
|
||||
if (driver != instance || evt is null) return;
|
||||
lock (_gate) _byEvent[evt] = _byEvent.GetValueOrDefault(evt) + value;
|
||||
});
|
||||
_listener.Start();
|
||||
}
|
||||
|
||||
public long this[string evt] { get { lock (_gate) return _byEvent.GetValueOrDefault(evt); } }
|
||||
public void Dispose() => _listener.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Each_event_lands_on_its_own_tagged_series()
|
||||
{
|
||||
var id = $"diag-{Guid.NewGuid():N}";
|
||||
using var cap = new Capture(id);
|
||||
var d = new OpcUaClientDiagnostics(id);
|
||||
|
||||
d.Record(OpcUaClientDiagnostics.PublishResponse);
|
||||
d.Record(OpcUaClientDiagnostics.Notification, 3);
|
||||
d.Record(OpcUaClientDiagnostics.DataChange);
|
||||
d.Record(OpcUaClientDiagnostics.BadStatusValue);
|
||||
d.Record(OpcUaClientDiagnostics.PublishError);
|
||||
d.Record(OpcUaClientDiagnostics.KeepAliveMiss);
|
||||
d.Record(OpcUaClientDiagnostics.ReconnectAttempt);
|
||||
d.Record(OpcUaClientDiagnostics.ReconnectSuccess);
|
||||
d.Record(OpcUaClientDiagnostics.ReconnectExhausted);
|
||||
|
||||
cap[OpcUaClientDiagnostics.PublishResponse].ShouldBe(1);
|
||||
cap[OpcUaClientDiagnostics.Notification].ShouldBe(3);
|
||||
cap[OpcUaClientDiagnostics.DataChange].ShouldBe(1);
|
||||
cap[OpcUaClientDiagnostics.BadStatusValue].ShouldBe(1);
|
||||
cap[OpcUaClientDiagnostics.PublishError].ShouldBe(1);
|
||||
cap[OpcUaClientDiagnostics.KeepAliveMiss].ShouldBe(1);
|
||||
cap[OpcUaClientDiagnostics.ReconnectAttempt].ShouldBe(1);
|
||||
cap[OpcUaClientDiagnostics.ReconnectSuccess].ShouldBe(1);
|
||||
cap[OpcUaClientDiagnostics.ReconnectExhausted].ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Another_instances_events_are_not_attributed_to_this_one()
|
||||
{
|
||||
var mine = $"diag-{Guid.NewGuid():N}";
|
||||
using var cap = new Capture(mine);
|
||||
|
||||
// Positive control: my own event IS seen; the other instance's identical event is not.
|
||||
new OpcUaClientDiagnostics("someone-else").Record(OpcUaClientDiagnostics.KeepAliveMiss);
|
||||
new OpcUaClientDiagnostics(mine).Record(OpcUaClientDiagnostics.PublishError);
|
||||
|
||||
cap[OpcUaClientDiagnostics.PublishError].ShouldBe(1);
|
||||
cap[OpcUaClientDiagnostics.KeepAliveMiss].ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Concurrent_records_do_not_lose_updates()
|
||||
{
|
||||
var id = $"diag-{Guid.NewGuid():N}";
|
||||
using var cap = new Capture(id);
|
||||
var d = new OpcUaClientDiagnostics(id);
|
||||
const int threads = 8, each = 2_000;
|
||||
|
||||
Parallel.For(0, threads, _ =>
|
||||
{
|
||||
for (var i = 0; i < each; i++) d.Record(OpcUaClientDiagnostics.DataChange);
|
||||
});
|
||||
|
||||
cap[OpcUaClientDiagnostics.DataChange].ShouldBe(threads * each);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Zero_or_negative_counts_are_ignored()
|
||||
{
|
||||
var id = $"diag-{Guid.NewGuid():N}";
|
||||
using var cap = new Capture(id);
|
||||
var d = new OpcUaClientDiagnostics(id);
|
||||
|
||||
d.Record(OpcUaClientDiagnostics.Notification, 0);
|
||||
d.Record(OpcUaClientDiagnostics.Notification, -2);
|
||||
d.Record(OpcUaClientDiagnostics.DataChange); // positive control
|
||||
|
||||
cap[OpcUaClientDiagnostics.Notification].ShouldBe(0);
|
||||
cap[OpcUaClientDiagnostics.DataChange].ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #275: the driver does not split Read/Write/CreateMonitoredItems itself because the SDK session already
|
||||
/// does — <c>Session</c> derives from <c>SessionClientBatched</c> (which batches by the server's
|
||||
/// OperationLimits, fetched on open). If an SDK bump ever drops that, this goes red and the driver needs
|
||||
/// its own chunking again.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void The_sdk_session_is_the_batching_session_type()
|
||||
{
|
||||
typeof(SessionClientBatched).IsAssignableFrom(typeof(Session)).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
+14
-5
@@ -8,14 +8,23 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientHistoryTests
|
||||
{
|
||||
/// <summary>Every <see cref="HistoryAggregateType"/> member (#285: the full Part 13 set, not just five).</summary>
|
||||
public static IEnumerable<object[]> AllAggregates() =>
|
||||
Enum.GetValues<HistoryAggregateType>().Select(a => new object[] { a });
|
||||
|
||||
/// <summary>Every member maps to a DISTINCT aggregate NodeId (a copy/paste mapping would collide).</summary>
|
||||
[Fact]
|
||||
public void MapAggregateToNodeId_is_injective_over_the_whole_enum()
|
||||
{
|
||||
var ids = Enum.GetValues<HistoryAggregateType>().Select(OpcUaClientDriver.MapAggregateToNodeId).ToList();
|
||||
ids.Distinct().Count().ShouldBe(ids.Count);
|
||||
Enum.GetValues<HistoryAggregateType>().Length.ShouldBeGreaterThan(5);
|
||||
}
|
||||
|
||||
/// <summary>Verifies MapAggregateToNodeId returns standard Part 13 aggregate for every enum.</summary>
|
||||
/// <param name="agg">The history aggregate type to test.</param>
|
||||
[Theory]
|
||||
[InlineData(HistoryAggregateType.Average)]
|
||||
[InlineData(HistoryAggregateType.Minimum)]
|
||||
[InlineData(HistoryAggregateType.Maximum)]
|
||||
[InlineData(HistoryAggregateType.Total)]
|
||||
[InlineData(HistoryAggregateType.Count)]
|
||||
[MemberData(nameof(AllAggregates))]
|
||||
public void MapAggregateToNodeId_returns_standard_Part13_aggregate_for_every_enum(HistoryAggregateType agg)
|
||||
{
|
||||
var nodeId = OpcUaClientDriver.MapAggregateToNodeId(agg);
|
||||
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>#273 subscription tuning, #277 revocation options, #278 discovery URL.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientTuningAndDiscoveryTests
|
||||
{
|
||||
// ---- #273 ----
|
||||
|
||||
[Fact]
|
||||
public void Defaults_equal_the_values_that_used_to_be_hardcoded()
|
||||
{
|
||||
var o = new OpcUaClientDriverOptions();
|
||||
|
||||
o.SubscriptionKeepAliveCount.ShouldBe(10u);
|
||||
o.SubscriptionLifetimeCount.ShouldBe(1000u);
|
||||
o.SubscriptionMaxNotificationsPerPublish.ShouldBe(0u);
|
||||
o.SubscriptionPriority.ShouldBe((byte)0);
|
||||
o.SubscriptionPublishingInterval.ShouldBeNull();
|
||||
o.DiscoveryUrl.ShouldBeNull();
|
||||
o.CertificateValidation.RejectUnknownRevocationStatus.ShouldBeFalse();
|
||||
o.CertificateValidation.IssuerStorePath.ShouldBeNull();
|
||||
Should.NotThrow(() => OpcUaClientDriver.ValidateTuning(o));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Factory_json_binds_the_new_keys_and_old_json_keeps_defaults()
|
||||
{
|
||||
var o = OpcUaClientDriverFactoryExtensions.ParseOptions("d", """
|
||||
{"subscriptionPublishingInterval":"00:00:02","subscriptionKeepAliveCount":20,
|
||||
"subscriptionLifetimeCount":600,"subscriptionMaxNotificationsPerPublish":500,
|
||||
"subscriptionPriority":9, "discoveryUrl":"opc.tcp://lds:4840","discoveryServerUri":"urn:plant:l1",
|
||||
"certificateValidation":{"rejectUnknownRevocationStatus":true,"issuerStorePath":"/pki/issuers"}}
|
||||
""");
|
||||
|
||||
o.SubscriptionPublishingInterval.ShouldBe(TimeSpan.FromSeconds(2));
|
||||
o.SubscriptionKeepAliveCount.ShouldBe(20u);
|
||||
o.SubscriptionLifetimeCount.ShouldBe(600u);
|
||||
o.SubscriptionMaxNotificationsPerPublish.ShouldBe(500u);
|
||||
o.SubscriptionPriority.ShouldBe((byte)9);
|
||||
o.DiscoveryUrl.ShouldBe("opc.tcp://lds:4840");
|
||||
o.DiscoveryServerUri.ShouldBe("urn:plant:l1");
|
||||
o.CertificateValidation.RejectUnknownRevocationStatus.ShouldBeTrue();
|
||||
o.CertificateValidation.IssuerStorePath.ShouldBe("/pki/issuers");
|
||||
|
||||
var legacy = OpcUaClientDriverFactoryExtensions.ParseOptions("d", """{"endpointUrl":"opc.tcp://x:1"}""");
|
||||
legacy.SubscriptionKeepAliveCount.ShouldBe(10u);
|
||||
legacy.SubscriptionLifetimeCount.ShouldBe(1000u);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0u, 1000u)] // keep-alive must be >= 1
|
||||
[InlineData(10u, 29u)] // lifetime < 3x keep-alive
|
||||
[InlineData(500u, 1000u)]
|
||||
public void ValidateTuning_rejects_out_of_range_counts(uint keepAlive, uint lifetime)
|
||||
{
|
||||
var o = new OpcUaClientDriverOptions { SubscriptionKeepAliveCount = keepAlive, SubscriptionLifetimeCount = lifetime };
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => OpcUaClientDriver.ValidateTuning(o));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateTuning_accepts_the_3x_boundary_and_rejects_bad_interval_or_discovery_url()
|
||||
{
|
||||
Should.NotThrow(() => OpcUaClientDriver.ValidateTuning(
|
||||
new OpcUaClientDriverOptions { SubscriptionKeepAliveCount = 10, SubscriptionLifetimeCount = 30 }));
|
||||
Should.Throw<InvalidOperationException>(() => OpcUaClientDriver.ValidateTuning(
|
||||
new OpcUaClientDriverOptions { SubscriptionPublishingInterval = TimeSpan.Zero }));
|
||||
Should.Throw<InvalidOperationException>(() => OpcUaClientDriver.ValidateTuning(
|
||||
new OpcUaClientDriverOptions { DiscoveryUrl = "not a uri" }));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, 1000, 1000)] // no override: the per-call value
|
||||
[InlineData(null, 10, 50)] // floor still applies to the per-call value
|
||||
[InlineData(2000, 250, 2000)] // override wins over the per-call value
|
||||
[InlineData(5, 250, 50)] // ... and is floored too
|
||||
public void ResolvePublishingIntervalMs_prefers_the_override_and_floors_at_50(int? overrideMs, int requestedMs, int expected)
|
||||
{
|
||||
var o = new OpcUaClientDriverOptions
|
||||
{
|
||||
SubscriptionPublishingInterval = overrideMs is { } ms ? TimeSpan.FromMilliseconds(ms) : null,
|
||||
};
|
||||
|
||||
OpcUaClientDriver.ResolvePublishingIntervalMs(o, TimeSpan.FromMilliseconds(requestedMs)).ShouldBe(expected);
|
||||
}
|
||||
|
||||
// ---- #277 ----
|
||||
|
||||
[Fact]
|
||||
public void Revocation_failures_are_exactly_Revoked_and_IssuerRevoked()
|
||||
{
|
||||
OpcUaClientDriver.IsRevocationFailure(StatusCodes.BadCertificateRevoked).ShouldBeTrue();
|
||||
OpcUaClientDriver.IsRevocationFailure(StatusCodes.BadCertificateIssuerRevoked).ShouldBeTrue();
|
||||
// "unknown" is governed by RejectUnknownRevocationStatus, not auto-accept's hard exception;
|
||||
// untrusted is what auto-accept exists to wave through.
|
||||
OpcUaClientDriver.IsRevocationFailure(StatusCodes.BadCertificateRevocationUnknown).ShouldBeFalse();
|
||||
OpcUaClientDriver.IsRevocationFailure(StatusCodes.BadCertificateUntrusted).ShouldBeFalse();
|
||||
OpcUaClientDriver.IsRevocationFailure(StatusCodes.Good).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---- #278 ----
|
||||
|
||||
private static EndpointDescription Ep(string url, string policyUri, MessageSecurityMode mode) =>
|
||||
new() { EndpointUrl = url, SecurityPolicyUri = policyUri, SecurityMode = mode };
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpointCandidates_without_discovery_is_unchanged()
|
||||
{
|
||||
var o = new OpcUaClientDriverOptions { EndpointUrls = ["opc.tcp://a:1", "opc.tcp://b:2"] };
|
||||
|
||||
OpcUaClientDriver.ResolveEndpointCandidates(o, []).ShouldBe(["opc.tcp://a:1", "opc.tcp://b:2"]);
|
||||
OpcUaClientDriver.ResolveEndpointCandidates(o).ShouldBe(["opc.tcp://a:1", "opc.tcp://b:2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpointCandidates_puts_configured_endpoints_before_discovered_and_dedups()
|
||||
{
|
||||
var o = new OpcUaClientDriverOptions { EndpointUrls = ["opc.tcp://Static:1", "opc.tcp://found:2"] };
|
||||
|
||||
var merged = OpcUaClientDriver.ResolveEndpointCandidates(o, ["opc.tcp://found:2", "opc.tcp://new:3"]);
|
||||
|
||||
merged.ShouldBe(["opc.tcp://Static:1", "opc.tcp://found:2", "opc.tcp://new:3"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpointCandidates_uses_the_single_EndpointUrl_before_discovered_when_no_list()
|
||||
{
|
||||
var o = new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://only:9" };
|
||||
|
||||
OpcUaClientDriver.ResolveEndpointCandidates(o, ["opc.tcp://d:1"]).ShouldBe(["opc.tcp://only:9", "opc.tcp://d:1"]);
|
||||
}
|
||||
|
||||
private static OpcUaClientDriver.DiscoveredEndpoint De(string appUri, string url) => new(appUri, url);
|
||||
|
||||
[Fact]
|
||||
public void SelectDiscoveredUrls_with_one_distinct_server_uses_it_and_dedups()
|
||||
{
|
||||
var urls = OpcUaClientDriver.SelectDiscoveredUrls(
|
||||
[De("urn:a", "opc.tcp://a:1"), De("urn:a", "opc.tcp://A:1"), De("urn:a", "opc.tcp://a:2")], null);
|
||||
|
||||
urls.ShouldBe(["opc.tcp://a:1", "opc.tcp://a:2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectDiscoveredUrls_with_several_servers_and_no_filter_fails_naming_them()
|
||||
{
|
||||
var ex = Should.Throw<InvalidOperationException>(() => OpcUaClientDriver.SelectDiscoveredUrls(
|
||||
[De("urn:line1", "opc.tcp://l1:1"), De("urn:line2", "opc.tcp://l2:1")], null));
|
||||
|
||||
ex.Message.ShouldContain("urn:line1");
|
||||
ex.Message.ShouldContain("urn:line2");
|
||||
ex.Message.ShouldContain("DiscoveryServerUri");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectDiscoveredUrls_filter_picks_only_that_server_even_when_several_are_listed()
|
||||
{
|
||||
var urls = OpcUaClientDriver.SelectDiscoveredUrls(
|
||||
[De("urn:line1", "opc.tcp://l1:1"), De("urn:line2", "opc.tcp://l2:1"), De("urn:line2", "opc.tcp://l2:2")],
|
||||
"urn:line2");
|
||||
|
||||
urls.ShouldBe(["opc.tcp://l2:1", "opc.tcp://l2:2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectDiscoveredUrls_filter_matching_nothing_yields_empty_not_another_server()
|
||||
{
|
||||
OpcUaClientDriver.SelectDiscoveredUrls([De("urn:line1", "opc.tcp://l1:1")], "urn:other").ShouldBeEmpty();
|
||||
OpcUaClientDriver.SelectDiscoveredUrls([], null).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---- #273: validation at options load ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"subscriptionKeepAliveCount":0}""")]
|
||||
[InlineData("""{"subscriptionKeepAliveCount":10,"subscriptionLifetimeCount":29}""")]
|
||||
[InlineData("""{"discoveryUrl":"not a uri"}""")]
|
||||
public void ParseOptions_rejects_out_of_range_tuning_with_a_clear_message(string json)
|
||||
{
|
||||
var ex = Should.Throw<InvalidOperationException>(
|
||||
() => OpcUaClientDriverFactoryExtensions.ParseOptions("drv-bad", json));
|
||||
|
||||
ex.Message.ShouldContain("drv-bad");
|
||||
ex.Message.ShouldContain("invalid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Factory_CreateInstance_fails_fast_on_a_bad_tuning_config()
|
||||
{
|
||||
Should.Throw<InvalidOperationException>(() => OpcUaClientDriverFactoryExtensions.CreateInstance(
|
||||
"drv-bad", """{"subscriptionLifetimeCount":5}"""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterEndpointUrls_keeps_only_the_wanted_policy_and_mode()
|
||||
{
|
||||
const string b256 = "http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256";
|
||||
const string none = "http://opcfoundation.org/UA/SecurityPolicy#None";
|
||||
var endpoints = new[]
|
||||
{
|
||||
Ep("opc.tcp://h:1/none", none, MessageSecurityMode.None),
|
||||
Ep("opc.tcp://h:1/sign", b256, MessageSecurityMode.Sign),
|
||||
Ep("opc.tcp://h:1/enc", b256, MessageSecurityMode.SignAndEncrypt),
|
||||
Ep("", b256, MessageSecurityMode.SignAndEncrypt),
|
||||
};
|
||||
|
||||
OpcUaClientDriver.FilterEndpointUrls(endpoints, b256, MessageSecurityMode.SignAndEncrypt)
|
||||
.ShouldBe(["opc.tcp://h:1/enc"]);
|
||||
OpcUaClientDriver.FilterEndpointUrls(endpoints, none, MessageSecurityMode.None)
|
||||
.ShouldBe(["opc.tcp://h:1/none"]);
|
||||
}
|
||||
}
|
||||
+43
@@ -156,6 +156,49 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
|
||||
result.ProbeTimeoutSeconds.ShouldBe(25);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #273/#275/#277/#278: the typed form rewrites the whole record on save, so a key it does not model
|
||||
/// is silently dropped (#576). Every new option must survive FromRecord -> ToRecord.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FormModel_RoundTrip_PreservesTuningLimitsRevocationAndDiscovery()
|
||||
{
|
||||
var original = new OpcUaClientDriverOptions
|
||||
{
|
||||
SubscriptionPublishingInterval = TimeSpan.FromMilliseconds(2500),
|
||||
SubscriptionKeepAliveCount = 20,
|
||||
SubscriptionLifetimeCount = 600,
|
||||
SubscriptionMaxNotificationsPerPublish = 500,
|
||||
SubscriptionPriority = 9,
|
||||
CertificateValidation = new OpcUaCertificateValidationOptions
|
||||
{
|
||||
RejectUnknownRevocationStatus = true,
|
||||
IssuerStorePath = "/pki/issuers",
|
||||
},
|
||||
DiscoveryUrl = "opc.tcp://lds:4840",
|
||||
DiscoveryServerUri = "urn:plant:line1",
|
||||
};
|
||||
|
||||
var back = OpcUaClientDriverForm.OpcUaClientFormModel.FromRecord(original).ToRecord([]);
|
||||
|
||||
back.SubscriptionPublishingInterval.ShouldBe(TimeSpan.FromMilliseconds(2500));
|
||||
back.SubscriptionKeepAliveCount.ShouldBe(20u);
|
||||
back.SubscriptionLifetimeCount.ShouldBe(600u);
|
||||
back.SubscriptionMaxNotificationsPerPublish.ShouldBe(500u);
|
||||
back.SubscriptionPriority.ShouldBe((byte)9);
|
||||
back.DiscoveryServerUri.ShouldBe("urn:plant:line1");
|
||||
back.CertificateValidation.RejectUnknownRevocationStatus.ShouldBeTrue();
|
||||
back.CertificateValidation.IssuerStorePath.ShouldBe("/pki/issuers");
|
||||
back.DiscoveryUrl.ShouldBe("opc.tcp://lds:4840");
|
||||
|
||||
// Defaults round-trip as defaults (blank override stays null, not 0 ms).
|
||||
var def = OpcUaClientDriverForm.OpcUaClientFormModel.FromRecord(new OpcUaClientDriverOptions()).ToRecord([]);
|
||||
def.SubscriptionPublishingInterval.ShouldBeNull();
|
||||
def.SubscriptionKeepAliveCount.ShouldBe(10u);
|
||||
def.SubscriptionLifetimeCount.ShouldBe(1000u);
|
||||
def.DiscoveryUrl.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EndpointUrlRow_FromUrl_ToUrl_Trims()
|
||||
{
|
||||
|
||||
+411
@@ -0,0 +1,411 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #629 over the wire — every child of a materialised Part 9 condition (ActiveState/Id,
|
||||
/// ShelvingState/UnshelveTime, Message, …) carries an instance-scoped NodeId in the condition's own
|
||||
/// namespace, so a client that translates a child's browse path and then Reads it gets THIS condition's
|
||||
/// value — not the condition type's ns=0 instance declaration.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Before #629 <c>AlarmConditionState.Create(..., assignNodeIds: true)</c> asked the node manager's
|
||||
/// <c>INodeIdFactory.New</c> for each child's id, and <c>CustomNodeManager2.New</c> returns the node's
|
||||
/// CURRENT id — the type's instance-declaration id (<c>i=9189</c> for UnshelveTime). Every condition's
|
||||
/// children therefore shared those ns=0 ids, a translate returned them, and a Read of one was served
|
||||
/// by the core node manager from the TYPE, never reaching the condition.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Each read asserts a value only the instance holds (projected Active, unacked, a distinguishing
|
||||
/// message, a timed shelve's remaining time), so a type-node answer fails it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ConditionChildNodeIdTests
|
||||
{
|
||||
private const NodePermissions BrowseRead = NodePermissions.Browse | NodePermissions.Read;
|
||||
|
||||
private static readonly string[][] ChildPaths =
|
||||
[
|
||||
["ActiveState", "Id"],
|
||||
["AckedState", "Id"],
|
||||
["EnabledState", "Id"],
|
||||
["Severity"],
|
||||
["Message"],
|
||||
["Retain"],
|
||||
["ShelvingState", "UnshelveTime"],
|
||||
["ShelvingState", "CurrentState", "Id"],
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Translating a condition child's browse path returns an instance-scoped NodeId in the condition's
|
||||
/// namespace (derived from the condition id), and a Read of that NodeId returns the instance's value —
|
||||
/// for a native (raw) and a scripted (UNS) condition, with ACL enforcement on and off.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(true, true)]
|
||||
[InlineData(false, true)]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(false, false)]
|
||||
public async Task A_condition_child_resolves_to_an_instance_node_that_reads_the_instance_value(bool native, bool aclOn)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync($"CondChildIds{native}{aclOn}", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(aclOn, [Key()], ClusterGrant(KeyPrincipal, "acl-key", BrowseRead));
|
||||
rig.MaterialiseNative();
|
||||
rig.MaterialiseScripted();
|
||||
ProjectDistinguishingStates(rig);
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var condition = ConditionNodeId(session, native);
|
||||
var conditionId = (string)condition.Identifier;
|
||||
|
||||
var resolved = await TranslateAsync(session, condition, ChildPaths, ct);
|
||||
for (var i = 0; i < ChildPaths.Length; i++)
|
||||
{
|
||||
var path = string.Join('/', ChildPaths[i]);
|
||||
resolved[i].NamespaceIndex.ShouldBe(condition.NamespaceIndex, $"{path} lives in the condition's namespace");
|
||||
resolved[i].Identifier.ShouldBe($"{conditionId}/{path}", $"{path} is scoped to the condition instance");
|
||||
}
|
||||
|
||||
var values = await ReadAsync(session, resolved, ct);
|
||||
for (var i = 0; i < values.Count; i++)
|
||||
StatusCode.IsGood(values[i].StatusCode).ShouldBeTrue($"{string.Join('/', ChildPaths[i])}: {values[i].StatusCode}");
|
||||
|
||||
var expectedMessage = native ? "native-distinct" : "scripted-distinct";
|
||||
values[0].Value.ShouldBe(true, "ActiveState/Id — the instance is active");
|
||||
values[1].Value.ShouldBe(false, "AckedState/Id — the instance is unacknowledged");
|
||||
values[2].Value.ShouldBe(true, "EnabledState/Id");
|
||||
values[3].Value.ShouldBe(
|
||||
rig.Nm.TryGetAlarmCondition(native ? RawAlarmPath : ScriptedAlarmId, native ? AddressSpaceRealm.Raw : AddressSpaceRealm.Uns)!
|
||||
.Severity.Value,
|
||||
"Severity — the instance's mapped severity");
|
||||
((ushort)values[3].Value).ShouldBeGreaterThan((ushort)0);
|
||||
(values[4].Value as LocalizedText)?.Text.ShouldBe(expectedMessage, "Message");
|
||||
values[5].Value.ShouldBe(true, "Retain — an active unacked condition is retained");
|
||||
if (native)
|
||||
{
|
||||
((double)values[6].Value).ShouldBe(0d, "UnshelveTime — a native condition is never shelved");
|
||||
values[7].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
|
||||
}
|
||||
else
|
||||
{
|
||||
((double)values[6].Value).ShouldBeInRange(50_000d, 60_000d, "UnshelveTime — the instance's timed shelve");
|
||||
values[7].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_TimedShelved);
|
||||
}
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two conditions never share a child NodeId, and a browse of a condition lists its children under
|
||||
/// the same instance ids the translate returns.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Children_of_different_conditions_have_distinct_ids_and_browse_agrees_with_translate()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync("CondChildDistinct", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", BrowseRead));
|
||||
rig.MaterialiseNative();
|
||||
rig.MaterialiseScripted();
|
||||
rig.Sink.MaterialiseAlarmCondition("scripted-eqa-lo", EqA, "lo", "AlarmCondition", 300, AddressSpaceRealm.Uns);
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
|
||||
var conditions = new[]
|
||||
{
|
||||
ConditionNodeId(session, native: true), ConditionNodeId(session, native: false), new NodeId("scripted-eqa-lo", ns),
|
||||
};
|
||||
|
||||
var all = new List<NodeId>();
|
||||
foreach (var c in conditions) all.AddRange(await TranslateAsync(session, c, ChildPaths, ct));
|
||||
all.Distinct().Count().ShouldBe(all.Count, "no two conditions share a child NodeId");
|
||||
all.ShouldAllBe(id => id.NamespaceIndex != 0, "no child resolves to a type's ns=0 instance declaration");
|
||||
|
||||
// Browse the scripted condition's ShelvingState: the forward references are its instance children.
|
||||
var shelving = (await TranslateAsync(session, conditions[1], [["ShelvingState"]], ct))[0];
|
||||
shelving.ShouldBe(new NodeId($"{ScriptedAlarmId}/ShelvingState", ns));
|
||||
var browsed = await BrowseChildrenAsync(session, shelving, ct);
|
||||
browsed.ShouldContain(new NodeId($"{ScriptedAlarmId}/ShelvingState/UnshelveTime", ns));
|
||||
browsed.ShouldContain(new NodeId($"{ScriptedAlarmId}/ShelvingState/CurrentState", ns));
|
||||
browsed.ShouldNotContain(VariableIds.AlarmConditionType_ShelvingState_UnshelveTime);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With ACL enforcement on, a condition child is governed like its condition: a Read grant on the
|
||||
/// raw device covers the condition's children (allow), and a grant that does not cover the condition
|
||||
/// denies them — the child neither falls through to an ungated type node nor to deny-all.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_condition_child_read_is_governed_by_the_conditions_scope()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync("CondChildAcl", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
// The key holds Read on the native condition's raw device only; it holds nothing on the scripted
|
||||
// condition (whose scope is its ScriptedAlarmId, under no granted path).
|
||||
var deviceGrant = new NodeAcl
|
||||
{
|
||||
NodeAclId = "acl-dev1",
|
||||
ClusterId = ServerEventRig.Cluster,
|
||||
Principal = KeyPrincipal,
|
||||
ScopeKind = NodeAclScopeKind.RawDevice,
|
||||
ScopeId = "dev-1",
|
||||
PermissionFlags = BrowseRead,
|
||||
};
|
||||
rig.Enforce(
|
||||
true,
|
||||
[Key()],
|
||||
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
|
||||
{
|
||||
[new AclScopeRef(NodeAclScopeKind.RawDevice, "dev-1")] =
|
||||
[new NodeAclPath([NodeAclRealms.Raw, "Plant", "Modbus", "dev1"])],
|
||||
},
|
||||
deviceGrant);
|
||||
rig.MaterialiseNative();
|
||||
rig.MaterialiseScripted();
|
||||
ProjectDistinguishingStates(rig);
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
|
||||
var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
|
||||
|
||||
var values = await ReadAsync(session,
|
||||
[
|
||||
new NodeId($"{RawAlarmPath}/Message", rawNs),
|
||||
new NodeId($"{RawAlarmPath}/ActiveState/Id", rawNs),
|
||||
new NodeId($"{ScriptedAlarmId}/Message", ns),
|
||||
new NodeId($"{ScriptedAlarmId}/ActiveState/Id", ns),
|
||||
], ct);
|
||||
|
||||
StatusCode.IsGood(values[0].StatusCode).ShouldBeTrue($"granted child read: {values[0].StatusCode}");
|
||||
(values[0].Value as LocalizedText)?.Text.ShouldBe("native-distinct");
|
||||
values[1].Value.ShouldBe(true);
|
||||
values[2].StatusCode.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied, "ungranted condition's child");
|
||||
values[3].StatusCode.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied, "ungranted condition's child");
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removing a condition removes its children too: once instance-scoped they are reachable by NodeId,
|
||||
/// so a child left indexed would keep answering a Read with the removed condition's last value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_removed_conditions_children_are_no_longer_readable()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync("CondChildRemove", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", BrowseRead));
|
||||
rig.MaterialiseNative();
|
||||
rig.MaterialiseScripted();
|
||||
ProjectDistinguishingStates(rig);
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
|
||||
var child = new NodeId($"{ScriptedAlarmId}/Message", ns);
|
||||
var nested = new NodeId($"{ScriptedAlarmId}/ShelvingState/UnshelveTime", ns);
|
||||
|
||||
// Positive control: readable while the condition exists.
|
||||
var before = await ReadAsync(session, [child, nested], ct);
|
||||
(before[0].Value as LocalizedText)?.Text.ShouldBe("scripted-distinct");
|
||||
StatusCode.IsGood(before[1].StatusCode).ShouldBeTrue();
|
||||
|
||||
rig.Nm.RemoveAlarmConditionNode(ScriptedAlarmId, AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||
|
||||
var after = await ReadAsync(session, [child, nested], ct);
|
||||
after[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadNodeIdUnknown);
|
||||
after[1].StatusCode.ShouldBe((StatusCode)StatusCodes.BadNodeIdUnknown);
|
||||
|
||||
// A rebuild forgets them the same way (the native condition's children, here).
|
||||
var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
|
||||
var rawChild = new NodeId($"{RawAlarmPath}/Message", rawNs);
|
||||
StatusCode.IsGood((await ReadAsync(session, [rawChild], ct))[0].StatusCode).ShouldBeTrue();
|
||||
rig.Nm.RebuildAddressSpace();
|
||||
(await ReadAsync(session, [rawChild], ct))[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadNodeIdUnknown);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The shelve methods resolve by the condition's OWN child method ids
|
||||
/// (<c>{condition}/ShelvingState/TimedShelve</c>, <c>…/Unshelve</c>) — the ids a browse of the condition
|
||||
/// returns — whether the call names the condition or its ShelvingState as the object, and each accepted
|
||||
/// call reports its one condition event.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Shelve_methods_called_by_their_instance_ids_succeed_and_report(bool objectIsShelvingState)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync($"CondChildMethodIds{objectIsShelvingState}", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", AllMethods));
|
||||
rig.MaterialiseScripted();
|
||||
var current = ConditionMethodEventTests.PreState("TimedShelve", native: false) with { Message = "base" };
|
||||
ConditionMethodEventTests.WireSimulatedOwners(rig, native: false, () => current, s => current = s);
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
rig.Project(false, current);
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
|
||||
|
||||
var condition = ConditionNodeId(session, native: false);
|
||||
var ns = condition.NamespaceIndex;
|
||||
var shelvingState = new NodeId($"{ScriptedAlarmId}/ShelvingState", ns);
|
||||
var target = objectIsShelvingState ? shelvingState : condition;
|
||||
|
||||
await session.CallAsync(target, new NodeId($"{ScriptedAlarmId}/ShelvingState/TimedShelve", ns), ct, 60_000d);
|
||||
await rig.DrainProjectionsAsync();
|
||||
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 2);
|
||||
events.ConditionEvents(item, "base")[1][RecordedEvents.ShelvingField].Value
|
||||
.ShouldBe(ObjectIds.ShelvedStateMachineType_TimedShelved);
|
||||
|
||||
await session.CallAsync(target, new NodeId($"{ScriptedAlarmId}/ShelvingState/Unshelve", ns), ct);
|
||||
await rig.DrainProjectionsAsync();
|
||||
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 3);
|
||||
events.ConditionEvents(item, "base")[2][RecordedEvents.ShelvingField].Value
|
||||
.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pins the #629 trade-off: the shelve methods resolve by the condition's instance ids and by the
|
||||
/// <c>AlarmConditionType_ShelvingState_*</c> ids every client uses (the positive control), but NOT by
|
||||
/// the <c>ShelvedStateMachineType_*</c> ids — those were the methods' SDK declarations, replaced by the
|
||||
/// AlarmConditionType ones when the methods moved off their ns=0 NodeIds. Change this deliberately.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_shelve_call_by_the_state_machine_types_method_id_is_not_supported()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync("CondChildMethodStateMachineId", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", AllMethods));
|
||||
rig.MaterialiseScripted();
|
||||
var current = ConditionMethodEventTests.PreState("OneShotShelve", native: false);
|
||||
ConditionMethodEventTests.WireSimulatedOwners(rig, native: false, () => current, s => current = s);
|
||||
rig.Project(false, current);
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var condition = ConditionNodeId(session, native: false);
|
||||
|
||||
var refused = await Should.ThrowAsync<ServiceResultException>(
|
||||
session.CallAsync(condition, MethodIds.ShelvedStateMachineType_OneShotShelve, ct));
|
||||
refused.StatusCode.ShouldBe(StatusCodes.BadMethodInvalid);
|
||||
|
||||
// Positive control: the AlarmConditionType declaration id resolves on the same condition.
|
||||
await session.CallAsync(condition, MethodIds.AlarmConditionType_ShelvingState_OneShotShelve, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
// ───────────────────────────── helpers ─────────────────────────────
|
||||
|
||||
private const NodePermissions AllMethods =
|
||||
NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.AlarmRead
|
||||
| NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm | NodePermissions.AlarmShelve
|
||||
| NodePermissions.MethodCall;
|
||||
|
||||
/// <summary>
|
||||
/// Native: active, unacked, "native-distinct". Scripted: active, unacked, "scripted-distinct", timed
|
||||
/// shelved for 60 s — none of which the condition type's instance declarations can hold.
|
||||
/// </summary>
|
||||
private static void ProjectDistinguishingStates(ServerEventRig rig)
|
||||
{
|
||||
rig.Project(native: true, new AlarmConditionSnapshot(
|
||||
Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: "native-distinct"));
|
||||
rig.Project(native: false, new AlarmConditionSnapshot(
|
||||
Active: true, Acknowledged: false, Confirmed: false, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Timed, Severity: 300, Message: "scripted-distinct",
|
||||
UnshelveAtUtc: DateTime.UtcNow.AddSeconds(60)));
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<NodeId>> TranslateAsync(
|
||||
ISession session, NodeId start, IReadOnlyList<string[]> paths, CancellationToken ct)
|
||||
{
|
||||
var browsePaths = new BrowsePathCollection();
|
||||
foreach (var path in paths)
|
||||
{
|
||||
var relative = new RelativePath();
|
||||
foreach (var name in path)
|
||||
{
|
||||
relative.Elements.Add(new RelativePathElement
|
||||
{
|
||||
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
|
||||
IsInverse = false,
|
||||
IncludeSubtypes = true,
|
||||
TargetName = new QualifiedName(name),
|
||||
});
|
||||
}
|
||||
browsePaths.Add(new BrowsePath { StartingNode = start, RelativePath = relative });
|
||||
}
|
||||
|
||||
var response = await session.TranslateBrowsePathsToNodeIdsAsync(null, browsePaths, ct);
|
||||
var results = new List<NodeId>();
|
||||
for (var i = 0; i < response.Results.Count; i++)
|
||||
{
|
||||
var r = response.Results[i];
|
||||
StatusCode.IsGood(r.StatusCode).ShouldBeTrue($"translate {string.Join('/', paths[i])}: {r.StatusCode}");
|
||||
r.Targets.Count.ShouldBe(1, $"translate {string.Join('/', paths[i])} has one target");
|
||||
results.Add(ExpandedNodeId.ToNodeId(r.Targets[0].TargetId, session.NamespaceUris));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<DataValue>> ReadAsync(ISession session, IReadOnlyList<NodeId> ids, CancellationToken ct)
|
||||
{
|
||||
var request = new ReadValueIdCollection(ids.Select(id => new ReadValueId { NodeId = id, AttributeId = Attributes.Value }));
|
||||
var response = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, request, ct);
|
||||
return response.Results;
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<NodeId>> BrowseChildrenAsync(ISession session, NodeId node, CancellationToken ct)
|
||||
{
|
||||
var request = new BrowseDescriptionCollection
|
||||
{
|
||||
new BrowseDescription
|
||||
{
|
||||
NodeId = node,
|
||||
BrowseDirection = BrowseDirection.Forward,
|
||||
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
|
||||
IncludeSubtypes = true,
|
||||
ResultMask = (uint)BrowseResultMask.All,
|
||||
},
|
||||
};
|
||||
var response = await session.BrowseAsync(null, null, 0, request, ct);
|
||||
return response.Results[0].References
|
||||
.Select(r => ExpandedNodeId.ToNodeId(r.NodeId, session.NamespaceUris))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -369,9 +369,8 @@ public sealed class ConditionMethodEventTests
|
||||
/// Gitea #597: both paths applied the shelve with <c>shelvingTime 0</c>, which the SDK reads as "the
|
||||
/// longest shelve allowed" — <c>MaxTimeShelved</c>, <c>int.MaxValue</c> ms — so every timed shelve
|
||||
/// advertised ~24.8 days remaining while the engine expired it on time. Read through the event select
|
||||
/// clause rather than a Read of the child node: the condition's children carry their type's
|
||||
/// instance-declaration NodeIds (<c>i=9189</c> for UnshelveTime), so a direct Read reaches the type, not
|
||||
/// this condition.
|
||||
/// clause, which is what an alarm client consumes. (A direct Read of the child also works since Gitea
|
||||
/// #629 gave condition children instance-scoped NodeIds — see <c>ConditionChildNodeIdTests</c>.)
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task A_scripted_timed_shelve_reports_the_real_remaining_unshelve_time()
|
||||
|
||||
@@ -74,10 +74,16 @@ internal sealed class ServerEventRig : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>Wires the authorizer over the given keys and grants, enforcing or not.</summary>
|
||||
public DataPlaneAuthorityState Enforce(bool enabled, ApiKey[] keys, params NodeAcl[] grants)
|
||||
public DataPlaneAuthorityState Enforce(bool enabled, ApiKey[] keys, params NodeAcl[] grants) =>
|
||||
Enforce(enabled, keys, ScopePaths(), grants);
|
||||
|
||||
/// <summary>Wires the authorizer over the given keys, scope paths and grants, enforcing or not.</summary>
|
||||
public DataPlaneAuthorityState Enforce(
|
||||
bool enabled, ApiKey[] keys, IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
|
||||
params NodeAcl[] grants)
|
||||
{
|
||||
var state = new DataPlaneAuthorityState();
|
||||
state.Apply(Cluster, "rev-1", keys, grants, ScopePaths());
|
||||
state.Apply(Cluster, "rev-1", keys, grants, scopePaths);
|
||||
Nm.DataPlaneAuthority = state;
|
||||
Nm.Authorizer = new DataPlaneAuthorizer(
|
||||
state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance, TimeProvider.System);
|
||||
|
||||
@@ -268,6 +268,52 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>#285: a Part 13 aggregate beyond the original five (Interpolative) is forwarded to the
|
||||
/// backend as its own <see cref="HistoryAggregateType"/> member.</summary>
|
||||
[Fact]
|
||||
public async Task Processed_extended_aggregate_dispatches_to_the_source()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var fake = new RecordingHistorianDataSource { ProcessedResult = Empty() };
|
||||
nm.HistorianDataSource = fake;
|
||||
nm.EnsureVariable("eq-1/interp", parentFolderNodeId: null, displayName: "I", dataType: "Float",
|
||||
writable: false, historianTagname: "WW.Interp", realm: AddressSpaceRealm.Uns);
|
||||
var nodeId = nm.TryGetVariable("eq-1/interp")!.NodeId;
|
||||
|
||||
InvokeHistoryRead(server, nm, new ReadProcessedDetails
|
||||
{
|
||||
StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0,
|
||||
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_Interpolative },
|
||||
}, nodeId);
|
||||
|
||||
fake.LastCall.ShouldBe("Processed");
|
||||
fake.LastAggregate.ShouldBe(HistoryAggregateType.Interpolative);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>#285: a backend that cannot serve a mapped aggregate answers BadAggregateNotSupported
|
||||
/// (not the generic BadHistoryOperationUnsupported a plain throw produces).</summary>
|
||||
[Fact]
|
||||
public async Task Processed_backend_unsupported_aggregate_yields_BadAggregateNotSupported()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
nm.HistorianDataSource = new RecordingHistorianDataSource { AggregateNotSupported = true };
|
||||
nm.EnsureVariable("eq-1/ns", parentFolderNodeId: null, displayName: "N", dataType: "Float",
|
||||
writable: false, historianTagname: "WW.Ns", realm: AddressSpaceRealm.Uns);
|
||||
var nodeId = nm.TryGetVariable("eq-1/ns")!.NodeId;
|
||||
|
||||
var (_, errors) = InvokeHistoryRead(server, nm, new ReadProcessedDetails
|
||||
{
|
||||
StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0,
|
||||
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_PercentGood },
|
||||
}, nodeId);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateNotSupported);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>AtTime read: the requested timestamps reach ReadAtTimeAsync in order.</summary>
|
||||
[Fact]
|
||||
public async Task AtTime_dispatches_requested_timestamps()
|
||||
@@ -430,6 +476,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
private sealed class RecordingHistorianDataSource : IHistorianDataSource
|
||||
{
|
||||
public bool ThrowOnRead { get; init; }
|
||||
public bool AggregateNotSupported { get; init; }
|
||||
public HistorianRead RawResult { get; set; } = new(Array.Empty<DataValueSnapshot>(), null);
|
||||
public HistorianRead ProcessedResult { get; set; } = new(Array.Empty<DataValueSnapshot>(), null);
|
||||
public HistorianRead AtTimeResult { get; set; } = new(Array.Empty<DataValueSnapshot>(), null);
|
||||
@@ -461,6 +508,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
HistoryAggregateType aggregate, CancellationToken cancellationToken)
|
||||
{
|
||||
if (ThrowOnRead) throw new InvalidOperationException("backend boom");
|
||||
if (AggregateNotSupported) throw new HistoryAggregateNotSupportedException(aggregate);
|
||||
LastCall = "Processed";
|
||||
LastTagname = fullReference;
|
||||
LastStart = startUtc;
|
||||
|
||||
Reference in New Issue
Block a user