Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c794ae33a5 | |||
| a21104d27c | |||
| 344bc9df8a | |||
| 09ac0e58cb | |||
| 8c422b9ccc | |||
| aac7c88417 | |||
| 6aaccb332f | |||
| 9cddc9306b | |||
| ac5e9d84f0 | |||
| 9712e66ff4 | |||
| 08354231f8 | |||
| 9efd7667e1 | |||
| b71620145b | |||
| a5bed28765 | |||
| 96fd67695f | |||
| 7314f159a0 | |||
| 3dcb035f89 | |||
| 33c7dc4292 | |||
| 4400127810 | |||
| f9a006dab4 | |||
| 52a4476b21 | |||
| 586ce9422d | |||
| 9af1a824b3 |
@@ -243,7 +243,7 @@
|
||||
<PackageVersion Include="ZB.MOM.WW.Secrets.Replicator.AkkaDotNet" Version="0.6.2" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.5.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.HistorianGateway.Client" Version="0.3.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.HistorianGateway.Contracts" Version="0.3.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.HistorianGateway.Client" Version="0.4.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.HistorianGateway.Contracts" Version="0.4.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+45
-1
@@ -22,6 +22,37 @@ Design references: [docs/plans/2026-06-14-galaxy-phase-c-historian-design.md](pl
|
||||
|
||||
---
|
||||
|
||||
## SDK 2.0.1 compatibility (#782)
|
||||
|
||||
The server targets exact OPC Foundation SDK **2.0.1**, replacing **1.5.378.182**.
|
||||
The upgrade preserves existing application results, including the SQL backends'
|
||||
documented divergences from the old SDK below. Live `AggregateFilter` calculations
|
||||
use an application-private source port of the **37 legacy aggregate factories**,
|
||||
registered through the public SDK aggregate manager; they do not adopt SDK 2's new
|
||||
algorithms. Runtime and advertised aggregate defaults retain
|
||||
`TreatUncertainAsBad=false`. History backend capability sets remain separate from
|
||||
the live aggregate set; no backend gains aggregates or history operations through
|
||||
this upgrade.
|
||||
|
||||
The isolated [original-SDK oracle](../scripts/validation/opcua-aggregate-baseline/README.md)
|
||||
generates **6,144 vectors** using the published 1.5.378.182 package, covering value
|
||||
kinds, statuses, timestamps/picoseconds, reverse reads and edge configurations.
|
||||
These are expected-output fixtures, not a claim that release validation passed.
|
||||
The candidate never loads the old SDK binary. Older references below to "the SDK"
|
||||
as an aggregate oracle mean that preserved baseline, rather than SDK 2's algorithms.
|
||||
|
||||
SDK FILETIME-zero timestamps are translated back to the application's existing CLR
|
||||
unset-time sentinel at history boundaries. Opaque history continuation points remain
|
||||
session-owned, node/topology-bound and subject to current authorization; reconnect or
|
||||
rollback requires a fresh read. Request cancellation flows to the backend. No
|
||||
ConfigDb migration is planned; actual schema and serialization effects must be
|
||||
recorded at the [rollout gate](plans/2026-11-10-opcua-sdk-2-rollout.md).
|
||||
|
||||
`HistorianGateway.Client` and `HistorianGateway.Contracts` are already **0.4.0**
|
||||
(on gateway master `16111a5e`, historiangw#119 closed). This package alignment is
|
||||
separate from the real gateway validation tracked by historiangw#78; it is not
|
||||
live AVEVA or backend-conformance evidence for SDK 2.
|
||||
|
||||
## Historized TagConfig schema
|
||||
|
||||
A tag is historized by setting the **Historize this tag** checkbox and optional **Historian
|
||||
@@ -306,7 +337,7 @@ and is registered the same way for every backend.
|
||||
|
||||
> **Cross-repo follow-up — a multi-tag batch `WriteLiveValues` on the gateway contract.** The drain's
|
||||
> unit of work is one gateway round-trip **per historian tag**, because
|
||||
> `ZB.MOM.WW.HistorianGateway.Client` **0.3.0** exposes no RPC that writes values for more than one tag
|
||||
> `ZB.MOM.WW.HistorianGateway.Client` **0.4.0** exposes no RPC that writes values for more than one tag
|
||||
> in a call. Bounded parallelism (`DrainConcurrency`) is therefore the ceiling: it overlaps the
|
||||
> round-trips but cannot remove them, so a wide tag set still pays `⌈tags / concurrency⌉` network
|
||||
> hops per drain pass. Collapsing that to a single hop needs a **batch/multi-tag `WriteLiveValues`** on
|
||||
@@ -314,6 +345,19 @@ and is registered the same way for every backend.
|
||||
> lands, raising `DrainBatchSize` widens each hop's payload but not the hop count — which is why the
|
||||
> concurrency window, not the batch width, is what governs drain throughput today.
|
||||
|
||||
The HistorianGateway **Client and Contracts packages are pinned together at 0.4.0**
|
||||
([historiangw#119](https://gitea.dohertylan.com/dohertj2/historiangw/issues/119)). The removed
|
||||
`ReadBlocks` RPC and `ReadBlocksAsync` wrapper were unused here. A partially imported CA PEM bundle now
|
||||
fails client creation instead of accepting a narrower certificate pin; repair the configured bundle if
|
||||
startup reports `ArgumentException` for `CaCertificatePath`.
|
||||
|
||||
This package update does not enable new gateway capabilities. OtOpcUa supplies its own non-empty event
|
||||
GUIDs, sends OPC DA quality bytes (at most 255), retains its UInt16-to-UInt4 provisioning policy, and does
|
||||
not consume the new store-forward buffer measurement fields. String backfill remains disabled: an older
|
||||
gateway ignores the new `StringValue` field and stores 0. Upgrade the gateway under
|
||||
[historiangw#78](https://gitea.dohertylan.com/dohertj2/historiangw/issues/78) before relying on string
|
||||
backfill, gateway-generated event IDs, UInt1-to-UInt2 creation, or other new server behavior.
|
||||
|
||||
### Typed values, real status and outage markers
|
||||
|
||||
The recorder no longer assumes "a numeric Good sample". Each mux delta is coerced to a typed `HistorianValue`
|
||||
|
||||
@@ -1,185 +1,88 @@
|
||||
# OPC UA Client test fixture
|
||||
|
||||
Coverage map + gap inventory for the OPC UA Client (gateway / aggregation)
|
||||
driver.
|
||||
This is the fixture and command map for the driver/browser upgrade from SDK
|
||||
**1.5.378.182** to exact **2.0.1** (#782). It describes available tests; execution,
|
||||
counts, skips and release decisions belong in [#782](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/782)
|
||||
and the linked [execution evidence](../plans/2026-11-10-opcua-sdk-2-evidence.md).
|
||||
A listed test or historical run is not candidate release evidence.
|
||||
|
||||
**TL;DR:** Wire-level coverage now exists via
|
||||
[opc-plc](https://github.com/Azure-Samples/iot-edge-opc-plc) — Microsoft
|
||||
Industrial IoT's OPC UA PLC simulator running in Docker (task #215). Real
|
||||
Secure Channel, real Session, real MonitoredItem exchange against an
|
||||
independent server implementation. Unit tests still carry the exhaustive
|
||||
capability matrix (cert auth / security policies / reconnect / failover /
|
||||
attribute mapping). Gaps remaining: upstream-server-specific quirks
|
||||
(historian aggregates, typed ConditionType events, SDK-publish-queue edge
|
||||
behavior under load) — opc-plc uses the same OPCFoundation stack internally
|
||||
so fully-independent-stack coverage needs `open62541/open62541` as a second
|
||||
image (follow-up).
|
||||
## Fixture layers
|
||||
|
||||
## What the fixture is
|
||||
| Layer | Repository location | Scope and limitation |
|
||||
|---|---|---|
|
||||
| Driver unit | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/` | Faked session contracts: lifecycle/generations, value conversion, per-item errors, policy selection, credentials, namespace remapping, subscription cleanup, discovery, alarms and host-owned reinitialization. No real secure channel. |
|
||||
| Browser unit | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/` | Browse attributes/pagination and certificate-identity ownership, including failure cleanup. No external server. |
|
||||
| In-process wire | `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/` | Real driver against OtOpcUa: `OpcUaClientDriverWireTests`, `OpcUaClientDriverStallWireTests`, `OpcUaClientDriverReconnectWireTests`. Real channels but both sides use this repository's SDK. |
|
||||
| External simulator | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/` | `OpcUaClientSmokeTests`: real read, varied-type batch read and subscription against opc-plc. Does not prove all upstream products, secured authentication, write or alarm compatibility. |
|
||||
| Browser integration | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/` | Separate fixture-backed browser tests; inspect fixture availability and individual results. |
|
||||
|
||||
**Integration layer** (task #215):
|
||||
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/` stands up
|
||||
`mcr.microsoft.com/iotedge/opc-plc:2.14.10` via `Docker/docker-compose.yml`
|
||||
on `opc.tcp://10.100.0.35:50000` (the shared Docker host; override via
|
||||
`OPCUA_SIM_ENDPOINT`). `OpcPlcFixture` probes the port at
|
||||
collection init + skips tests with a clear message when the container's
|
||||
not running (matches the Modbus/pymodbus + S7/python-snap7 skip pattern).
|
||||
Docker is the launcher — no PowerShell wrapper needed because opc-plc
|
||||
ships pre-containerized. Compose-file flags: `--ut` (unsecured transport
|
||||
advertised), `--aa` (auto-accept client certs — opc-plc's cert trust store
|
||||
resets on each spin-up), `--alm` (alarm simulation for IAlarmSource
|
||||
follow-up coverage), `--pn=50000` (port).
|
||||
The SDK 2 unit boundary cases include `OpcUaClientSdkValueBoundaryTests` and
|
||||
`OpcUaClientContinuationPointReleaseTests`. They preserve CLR value types and
|
||||
null/empty buffers, reject unsupported matrix items without shifting batch results,
|
||||
and release abandoned browse points. Certificate tests cover lifetime through
|
||||
session close; unit mocks alone do not establish trust-chain interoperability.
|
||||
|
||||
**Unit layer**:
|
||||
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/` is still the primary
|
||||
coverage. Tests inject fakes through the driver's construction path; the
|
||||
OPCFoundation.NetStandard `Session` surface is wrapped behind an interface
|
||||
the tests mock.
|
||||
The in-process stall proxy holds bytes without closing the session, then resumes;
|
||||
its hard-close case refuses connections. These exercise stale notification rejection,
|
||||
quality transitions and explicit host reinitialization. The driver does not use
|
||||
`SessionReconnectHandler` or `TransferSubscriptions`; historical transfer-based
|
||||
coverage descriptions do not apply to the current lifecycle (#670/#676).
|
||||
|
||||
## What it actually covers
|
||||
The driver has **no upstream HistoryRead capability** (#655). Its tags can be
|
||||
recorded into the local server-wide historian; backend conformance and aggregate
|
||||
oracles are documented in [Historian.md](../Historian.md).
|
||||
|
||||
### Integration (opc-plc Docker, task #215)
|
||||
## Running locally
|
||||
|
||||
- `OpcUaClientSmokeTests.Client_connects_and_reads_StepUp_node_through_real_OPC_UA_stack` —
|
||||
full Secure Channel + Session + `ns=3;s=StepUp` Read round-trip
|
||||
- `OpcUaClientSmokeTests.Client_reads_batch_of_varied_types_from_live_simulator` —
|
||||
batch Read of UInt32 / Int32 / Boolean; asserts `bool`-specific Variant
|
||||
decoding to catch a common attribute-mapping regression
|
||||
- `OpcUaClientSmokeTests.Client_subscribe_receives_StepUp_data_changes_from_live_server` —
|
||||
real `MonitoredItem` subscription against `ns=3;s=FastUInt1` (ticks every
|
||||
100 ms); asserts `OnDataChange` fires within 3 s of subscribe
|
||||
Run from the repository root. Build first and use a fresh result directory for
|
||||
**each** project. The [runbook](../plans/2026-11-10-opcua-sdk-2-rollout.md#verification-commands)
|
||||
provides the result helper that preserves test exit status and rejects all-skipped
|
||||
runs. For example, after defining that helper:
|
||||
|
||||
Wire-level surfaces verified: `IDriver` + `IReadable` + `ISubscribable` +
|
||||
`IHostConnectivityProbe` (via the Secure Channel exchange).
|
||||
```bash
|
||||
opc_test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj
|
||||
opc_test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests.csproj
|
||||
opc_test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj
|
||||
```
|
||||
|
||||
### In-process wire tests (no opc-plc)
|
||||
The simulator compose file is
|
||||
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml`.
|
||||
It launches `mcr.microsoft.com/iotedge/opc-plc:2.14.10` with `--ut --aa --alm --pn=50000`.
|
||||
Use the shared-host procedure in [infra/README.md](../../infra/README.md); coordinate
|
||||
shared fixture use before restarting containers. These dev flags do not establish
|
||||
production trust or secure-policy coverage.
|
||||
|
||||
`tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests` drives the real driver
|
||||
against the in-process OtOpcUa server (`ServerEventRig`, authenticating with the rig's API-key credential):
|
||||
read (+ missing node), write through the node-write gateway (+ read-only refusal, + failed-write revert),
|
||||
subscribe/unsubscribe, host-status Running -> not-Running when the server stops, and discovery. These need
|
||||
no Docker. There is no HistoryRead leg: the driver has no history capability (#655).
|
||||
`OpcPlcFixture` probes `OPCUA_SIM_ENDPOINT` (default `opc.tcp://10.100.0.35:50000`)
|
||||
and skips when unavailable. With an available, reserved fixture:
|
||||
|
||||
### Unit
|
||||
```bash
|
||||
export OPCUA_SIM_ENDPOINT=opc.tcp://10.100.0.35:50000
|
||||
opc_test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests.csproj
|
||||
```
|
||||
|
||||
The surface is broad because `OpcUaClientDriver` is the richest-capability
|
||||
driver in the fleet (it's a gateway for another OPC UA server, so it
|
||||
mirrors the full capability matrix):
|
||||
A nonzero suite count does not prove every required scenario ran: inspect individual
|
||||
TRX results and record required skips separately. No tests were run by preparing
|
||||
this documentation.
|
||||
|
||||
- `OpcUaClientDriverScaffoldTests` — `IDriver` lifecycle
|
||||
- `OpcUaClientReadWriteTests` — read + write lifecycle
|
||||
- `OpcUaClientSubscribeAndProbeTests` — monitored-item subscription + probe
|
||||
state transitions
|
||||
- `OpcUaClientDiscoveryTests` — `GetEndpoints` + endpoint selection
|
||||
- `OpcUaClientAttributeMappingTests` — OPC UA node attribute → driver value
|
||||
mapping
|
||||
- `OpcUaClientSecurityPolicyTests` — `SignAndEncrypt` / `Sign` / `None`
|
||||
policy negotiation contract
|
||||
- `OpcUaClientCertAuthTests` — cert store paths, revocation-list config
|
||||
- `OpcUaClientReconnectTests` — SDK reconnect hook + `TransferSubscriptions`
|
||||
across the disconnect boundary
|
||||
- `OpcUaClientFailoverTests` — primary → secondary session fallback per
|
||||
driver config
|
||||
- `OpcUaClientAlarmTests` — A&E severity bucket (1–1000 → Low / Medium /
|
||||
High / Critical), subscribe / unsubscribe / ack contract
|
||||
## Required compatibility evidence
|
||||
|
||||
Capability surfaces whose contract is verified: `IDriver`, `ITagDiscovery`,
|
||||
`IReadable`, `IWritable`, `ISubscribable`, `IHostConnectivityProbe`,
|
||||
`IAlarmSource`.
|
||||
Release validation must identify both application builds and SDK versions, endpoint
|
||||
policy, token kind, certificate chain, configuration and executed capabilities.
|
||||
Use the baseline client against the candidate server and candidate client against
|
||||
the baseline server, plus the actual ScadaBridge adapter and external upstreams.
|
||||
For secured paths include trusted SignAndEncrypt, API-key allow/deny/revocation,
|
||||
certificate rejection and restart/rebind. Apply only operations the peer implements;
|
||||
a simulator need not implement OtOpcUa's API-key or historian contract.
|
||||
|
||||
## What it does NOT cover
|
||||
For alarms verify typed EventFilter select clauses, result-field indexes, actual
|
||||
ConditionType/SourceNode/EventId identity, refresh, acknowledgements and no replay
|
||||
after an ambiguous transport failure. For all supported paths preserve existing
|
||||
operation statuses, locale behavior and null/empty filter meaning. Constructing an
|
||||
empty SDK 2 `ContentFilter` is an API adaptation, not evidence of a changed filter
|
||||
contract. Record any stricter protocol response and consumer impact before acceptance.
|
||||
|
||||
### 1. Full real-stack exchange (unit tests only)
|
||||
|
||||
The **unit** suite mocks `Session.ReadAsync`, `Session.CreateSubscription`,
|
||||
`Session.AddItem`, etc. — no UA Secure Channel is opened. The **integration**
|
||||
suite (`OpcUaClientSmokeTests`, task #215) does open a real Secure Channel
|
||||
against opc-plc and exercises Read + Subscribe end-to-end. What remains
|
||||
untested even in the integration suite: certificate validation under
|
||||
non-anonymous security policies, signing/encryption, nonce handling, chunk
|
||||
assembly, keep-alive cadence — all SDK-internal.
|
||||
|
||||
### 2. Subscription transfer across reconnect
|
||||
|
||||
Contract test: "after a simulated reconnect, `TransferSubscriptions` is
|
||||
called with the right handles." Real behavior: SDK re-publishes against the
|
||||
new channel and some events can be lost depending on publish-queue state.
|
||||
The lossy window is not characterized.
|
||||
|
||||
### 3. Large-scale subscription stress
|
||||
|
||||
100+ monitored items with heterogeneous publish intervals under a single
|
||||
session — the shape that breaks publish-queue-size tuning in the wild — is
|
||||
not exercised.
|
||||
|
||||
### 4. Real A&E events
|
||||
|
||||
Alarm subscription is mocked via filtered monitored items; the actual
|
||||
`EventFilter` select-clause behavior against a server that exposes typed
|
||||
ConditionType events (non-base `BaseEventType`) is not verified.
|
||||
|
||||
### 6. Authentication variants
|
||||
|
||||
- Anonymous, UserName/Password, X509 cert tokens — each is contract-tested
|
||||
but not exchanged against a server that actually enforces each.
|
||||
- LDAP-backed `UserName` (matching this repo's server-side
|
||||
`LdapOpcUaUserAuthenticator`) requires a live LDAP round-trip; not tested.
|
||||
|
||||
## When to trust OpcUaClient tests, when to reach for a server
|
||||
|
||||
| Question | Unit tests | Integration (opc-plc) | Real upstream server |
|
||||
| --- | --- | --- | --- |
|
||||
| "Does severity 750 bucket as High?" | yes | - | yes |
|
||||
| "Does the driver call `TransferSubscriptions` after reconnect?" | yes | - | yes |
|
||||
| "Does a real OPC UA read round-trip work?" | no | yes | yes |
|
||||
| "Does a real OPC UA subscribe deliver changes?" | no | yes | yes |
|
||||
| "Does write round-trip work against a live server?" | no | no (not yet exercised) | yes (required) |
|
||||
| "Does event-filter-based alarm subscription return ConditionType events?" | no | no | yes (required) |
|
||||
| "Does history read from AVEVA Historian return correct aggregates?" | no | no | yes (required) |
|
||||
| "Does the SDK's publish queue lose notifications under load?" | no | no | yes (stress) |
|
||||
|
||||
## Follow-up candidates
|
||||
|
||||
The easiest win here is to **wire the client driver tests against this
|
||||
repo's own server**. The v2 integration test project
|
||||
`tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs`
|
||||
(the v2 replacement for the retired v1 `OpcUaServerIntegrationTests`) already
|
||||
stands up a real OPC UA server on a non-default port with a seeded
|
||||
FakeDriver. An `OpcUaClientLiveLoopbackTests` that connects the client
|
||||
driver to that server would give:
|
||||
|
||||
- Real Secure Channel negotiation
|
||||
- Real Session / Subscription / MonitoredItem exchange
|
||||
- Real read/write round-trip
|
||||
- Real certificate validation (the integration test already sets up PKI)
|
||||
|
||||
It wouldn't cover upstream-server-specific quirks (AVEVA Historian, Kepware,
|
||||
Prosys), but it would cover 80% of the SDK surface the driver sits on top
|
||||
of.
|
||||
|
||||
Beyond that:
|
||||
|
||||
1. **Prosys OPC UA Simulation Server** — free, Windows-available, scriptable.
|
||||
2. **UaExpert Server-Side Simulator** — Unified Automation's sample server;
|
||||
good coverage of typed ConditionType events.
|
||||
3. **Dedicated historian integration lab** — only path for
|
||||
historian-specific coverage.
|
||||
|
||||
## Key fixture / config files
|
||||
|
||||
- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/` — unit tests with
|
||||
mocked `Session`
|
||||
- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcFixture.cs`
|
||||
— collection fixture; parses `OPCUA_SIM_ENDPOINT` (default
|
||||
`opc.tcp://10.100.0.35:50000`), TCP-probes at collection init, records
|
||||
`SkipReason` when unreachable
|
||||
- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs`
|
||||
— wire-level test suite (3 `[Fact]` methods: read, batch read, subscribe)
|
||||
- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml`
|
||||
— `mcr.microsoft.com/iotedge/opc-plc:2.14.10` with `--ut --aa --alm --pn=50000`
|
||||
- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs` — ctor +
|
||||
session-factory seam tests mock through; implements `IAlarmSource`;
|
||||
does NOT implement `IRediscoverable`
|
||||
- `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualEndpointTests.cs` —
|
||||
the v2 dual-endpoint integration harness a future loopback client test could
|
||||
piggyback on (v1 `OpcUaServerIntegrationTests.cs` retired with the v1 server project)
|
||||
opc-plc uses the OPC Foundation stack too. Independent upstream behavior, secured
|
||||
authentication variants, large publish queues, real alarm products, packet-loss
|
||||
windows and performance require separate evidence. The rollout runbook links the
|
||||
pending gates; no simulator run substitutes for ScadaBridge, live gateway, Windows,
|
||||
Linux or mixed-version cluster validation.
|
||||
|
||||
@@ -9,8 +9,9 @@ and for the test-harness map read [OpcUaClient-Test-Fixture.md](OpcUaClient-Test
|
||||
A **remote OPC UA server**. This driver runs the *opposite* direction from the
|
||||
usual "server exposes PLC data" flow: it acts as an OPC UA **client**, opens a
|
||||
`Session` against an upstream server, and re-exposes that server's address space
|
||||
through the local OtOpcUa server. Browse, read, write, subscribe, alarm, and
|
||||
history calls are passed through to the upstream endpoint.
|
||||
through the local OtOpcUa server. Browse, read, write, subscribe and alarm
|
||||
operations use the upstream endpoint. HistoryRead uses the local server-wide
|
||||
historian instead; see [History](#history-not-supported-gitea-655).
|
||||
|
||||
It is built on the OPC Foundation UA .NET Standard reference SDK and runs
|
||||
in-process in the OtOpcUa server's .NET 10 AnyCPU host — pure managed, no
|
||||
@@ -20,6 +21,31 @@ out-of-process isolation.
|
||||
> a remote OPC UA endpoint by hand, point the general-purpose
|
||||
> [Client CLI](../Client.CLI.md) at it directly.
|
||||
|
||||
## SDK 2.0.1 compatibility (#782)
|
||||
|
||||
The driver, shared session layer and AdminUI browser target exact SDK **2.0.1**
|
||||
(previous **1.5.378.182**). Their existing abstractions and host-owned reconnect
|
||||
lifecycle remain in place. SDK immutable values are detached at the boundary into
|
||||
existing CLR scalars and one-dimensional arrays, preserving null/empty ByteStrings,
|
||||
UTC timestamps, Guid values, numeric widths, item order and per-item status. Matrix
|
||||
payloads remain unsupported and fail per item; this upgrade adds no matrix or
|
||||
upstream-history capability.
|
||||
|
||||
Certificate-based identities retain their loaded certificate until their owning
|
||||
session closes, including failure cleanup. The browser releases abandoned browse
|
||||
continuation points. Endpoint policy selection, namespace-URI remapping, old-session
|
||||
notification rejection and the existing refusal to replay non-idempotent writes or
|
||||
alarm acknowledgements remain compatibility requirements.
|
||||
|
||||
No application status-code, locale-selection or event-filter semantic change is
|
||||
approved by this upgrade. SDK value wrappers and empty-filter construction are API
|
||||
adaptations; callers must continue to inspect operation and select-clause results
|
||||
by index. Confirm actual client/server combinations with the
|
||||
[fixture map](OpcUaClient-Test-Fixture.md) and
|
||||
[rollout gates](../plans/2026-11-10-opcua-sdk-2-rollout.md) before deployment.
|
||||
Historical SDK 1.5 observations elsewhere on this page describe their original
|
||||
measurements; they are not fresh SDK 2.0.1 interoperability evidence.
|
||||
|
||||
## Project split
|
||||
|
||||
| Project | Target | Role |
|
||||
@@ -365,7 +391,7 @@ They are not part of `DriverHealth` or the
|
||||
## Testing
|
||||
|
||||
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/`
|
||||
cover the session lifecycle, namespace remapping, alarm/history passthrough,
|
||||
cover the session lifecycle, namespace remapping, alarm dispatch,
|
||||
and config binding against a faked SDK session.
|
||||
- **Integration fixture** — exercises the driver against a reference OPC UA
|
||||
server (opc-plc) on the shared docker host; see
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
# OPC UA SDK 2.0.1 rollout and rollback
|
||||
|
||||
This operator runbook accompanies [#782](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/782),
|
||||
the [implementation plan](2026-11-10-opcua-sdk-2-upgrade.md) and
|
||||
[execution evidence](2026-11-10-opcua-sdk-2-evidence.md). Gitea is the single
|
||||
status system. This draft was checked against source `a21104d2`; it does not
|
||||
approve an artifact or record a deployment. The coordinator must attach final
|
||||
commit-specific evidence in #782 before advancing a gate. All placeholders below
|
||||
are **pending evidence**, not passing results.
|
||||
|
||||
The target is exact OPC Foundation SDK **2.0.1**; the known-good SDK baseline is
|
||||
**1.5.378.182** (application baseline `83ed880e4471344deaf008da9bd028c7e170bdc7`).
|
||||
Restore the actual archived known-good application as a unit during rollback.
|
||||
Package downgrades alone cannot reverse the application/API migration.
|
||||
|
||||
## Operating constraints
|
||||
|
||||
**The remote-execution hold on wonder boxes vd03/vq03/vq04 remains binding.**
|
||||
No WinRM, psbridge or replacement remote-exec channel may be used until the user
|
||||
clears a channel. UNC/SMB file copying and permitted SQL access do not authorize
|
||||
service control, PowerShell execution or deployment on those hosts. Steps requiring
|
||||
a command on a held host remain pending. No deployment was performed for this draft.
|
||||
|
||||
Run the service commands below only from an approved local operator session on an
|
||||
authorized Windows node. Record the node, account, channel and maintenance window
|
||||
in #782 first. Linux runtime/service handling requires its own approved environment
|
||||
and smoke record; this runbook supplies no invented Linux service-unit name.
|
||||
Do not use `scripts/install/Refresh-Services.ps1` as a blanket upgrade action: it
|
||||
also touches gateway/retired sidecar paths outside this SDK rollout.
|
||||
|
||||
## Preserved contract
|
||||
|
||||
- Existing raw/UNS namespace URIs, identifier forms, endpoint profiles, API-key/anonymous
|
||||
admission, ACL flag behavior, role and key revocation gates remain the contract.
|
||||
Source uses public transport peer evidence for miss budgets, not an IP admission rule.
|
||||
- Scalar leniency, checked numeric-array conversion and pass-through driver behavior
|
||||
stay as before. SDK immutable values are detached at boundaries; no new matrix support.
|
||||
- No application status-code, locale or event-filter semantic change is approved.
|
||||
SDK status wrappers and explicit empty `ContentFilter` construction are API changes,
|
||||
not evidence of changed protocol values. Record any stricter response, field-level
|
||||
filter error or localization difference with a consumer-impact decision before go.
|
||||
- Auditing stays opt-in. Ordinary SDK automatic audit delivery retains its type
|
||||
restrictions. Only the application's rejected-write audit gets a private, temporary
|
||||
receive-permission context after current source `Read` ACL and audit/encryption checks;
|
||||
no shared role metadata or general audit access is widened. See [security](../security.md).
|
||||
- The private source port retains the **37 legacy aggregate factories** and existing
|
||||
application results rather than adopting SDK 2 algorithms. Original-SDK fixtures
|
||||
contain **6,144 vectors**. `TreatUncertainAsBad=false` stays explicit; historian
|
||||
backend aggregate sets and existing documented differences remain unchanged.
|
||||
SDK FILETIME-zero maps to the existing CLR unset-time sentinel at history boundaries.
|
||||
- No new historian backend, companion specification, PubSub, ManagedSession, AOT or
|
||||
cluster rolling-upgrade capability is claimed. HistorianGateway Client/Contracts
|
||||
**0.4.0** is already on gateway master `16111a5e` (historiangw#119 closed);
|
||||
the real gateway gate historiangw#78 remains separate from package alignment.
|
||||
|
||||
## Release record and preflight
|
||||
|
||||
Fill this record in #782 with links to immutable logs/manifests; do not paste keys,
|
||||
passwords, connection strings, private certificates or unredacted environment dumps.
|
||||
|
||||
| Required evidence | Record to supply before release |
|
||||
|---|---|
|
||||
| Candidate source and graph | Pending: full commit, clean/dirty state, SDK/runtime/OS, resolved package graph showing exact 2.0.1 |
|
||||
| Known-good recovery set | Pending: deployed application version, full binary/config archive SHA-256, protected PKI backup location and permission record |
|
||||
| Candidate artifacts | Pending: Windows and Linux publish directories/archives, RID/self-contained choice, SHA-256 manifest, dependencies and script-worker contents |
|
||||
| G2 compilation | Pending: restore/build logs and warning/error counts at the final SHA |
|
||||
| G3 behavior/security | Pending: project TRX/counts, required case results, positive and negative authorization, audit privacy, CP lifecycle and 6,144-vector comparison |
|
||||
| Interoperability | Pending: baseline/candidate client-server pairs, actual ScadaBridge adapter, upstream driver/browser products and mixed-version cluster gate |
|
||||
| Historian | Pending: configured SqlServer/QuestDB/Gateway capability-specific results; historiangw#78 link/evidence for live gateway |
|
||||
| G4 resources/performance | Pending: baseline comparison, at least three measured runs, two-hour soak, Windows/Linux start-stop-twice smoke with preserved PKI |
|
||||
| Review/operations | Pending: independent security/release review, authorized channel/window, named operator, canary/rollback results and decision |
|
||||
|
||||
Before staging:
|
||||
|
||||
1. Inventory actual service executable/arguments, roles, endpoints, advertised host,
|
||||
application URI, raw/UNS namespace identities, certificate thumbprints, active/standby
|
||||
state and connected client builds. Record configured queues, request/transport quotas,
|
||||
history caps and expected load. Establish performance budgets and stop thresholds.
|
||||
2. Archive complete known-good binaries and node configuration, preserving access controls.
|
||||
Securely back up PKI own/private keys, peer/issuer chains, CRLs and their permissions.
|
||||
Verify recovery access under the service identity, private-key presence, expiry,
|
||||
application URI/hostname match and CA/key-usage suitability. Retain existing certificates.
|
||||
3. Inventory ConfigDb, LocalDb, historian/outbox and secret-provider state/locations. No
|
||||
ConfigDb migration is planned. Record the actual schema/serialization diff, including
|
||||
Akka/deployment contracts; if persistence changes appear, reopen the design gate and
|
||||
define data rollback before deployment. Never overwrite live historian/outbox data
|
||||
with an old binary backup or rewrite persisted sessions/subscriptions.
|
||||
4. Publish into fresh directories, hash artifacts, stage separately from the live install
|
||||
and validate graph contents. Keep baseline and candidate ports, PKI, config and stores
|
||||
separate in the isolated rig; never run both against the same mutable fixture.
|
||||
5. Confirm required positive/negative checks, reviewers and recovery artifact access.
|
||||
Missing evidence, inaccessible environments or an uncleared command channel means hold.
|
||||
|
||||
## Verification commands
|
||||
|
||||
These commands are recipes, not execution results. Run from the repository root in
|
||||
Bash with .NET 10. Record the final SHA and logs in a fresh evidence directory; inspect
|
||||
exit status before continuing. Do not pipe a failing build/test into a successful logger.
|
||||
|
||||
```bash
|
||||
git rev-parse HEAD
|
||||
git status --short
|
||||
dotnet --info
|
||||
dotnet restore ZB.MOM.WW.OtOpcUa.slnx
|
||||
dotnet build ZB.MOM.WW.OtOpcUa.slnx -c Release --no-restore
|
||||
```
|
||||
|
||||
Define the helper once after a successful build. Each invocation creates its own
|
||||
fresh TRX directory. It preserves both test failure and zero-executed-test failure.
|
||||
A successful guard still requires inspection of required cases and skips.
|
||||
|
||||
```bash
|
||||
opc_test() {
|
||||
local opc_project="$1"
|
||||
local opc_results
|
||||
opc_results=$(mktemp -d "${TMPDIR:-/tmp}/opcua2-test.XXXXXXXX") || return
|
||||
local opc_test_status=0
|
||||
dotnet test "$opc_project" -c Release --no-build --logger trx \
|
||||
--results-directory "$opc_results" || opc_test_status=$?
|
||||
local opc_count_status=0
|
||||
MIN_EXECUTED=1 bash scripts/ci/assert-not-all-skipped.sh "$opc_results"/*.trx \
|
||||
|| opc_count_status=$?
|
||||
printf 'TRX directory: %s\n' "$opc_results"
|
||||
if [ "$opc_test_status" -ne 0 ]; then return "$opc_test_status"; fi
|
||||
return "$opc_count_status"
|
||||
}
|
||||
opc_test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.csproj
|
||||
opc_test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj
|
||||
opc_test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests.csproj
|
||||
opc_test tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj
|
||||
opc_test tests/Client/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.csproj
|
||||
```
|
||||
|
||||
Driver/browser and simulator commands are in the
|
||||
[fixture map](../drivers/OpcUaClient-Test-Fixture.md#running-locally). Backend and
|
||||
live historian prerequisites are in [Historian.md](../Historian.md) and
|
||||
[infra/README.md](../../infra/README.md). Live tests can write sandbox data: use
|
||||
only designated sandbox tags and approved credentials through the existing secret
|
||||
provider/environment. A skipped live/DB fixture never satisfies its gate.
|
||||
Generate original aggregate evidence only with the isolated
|
||||
[oracle procedure](../../scripts/validation/opcua-aggregate-baseline/README.md),
|
||||
a new output path and original 1.5.378.182 binary; never derive expectations from
|
||||
the candidate's calculators.
|
||||
|
||||
Also run the full CI-equivalent test leg after integration; it does not replace
|
||||
fixture-specific or live gates:
|
||||
|
||||
```bash
|
||||
opc_solution_test() {
|
||||
local opc_results
|
||||
opc_results=$(mktemp -d "${TMPDIR:-/tmp}/opcua2-solution.XXXXXXXX") || return
|
||||
local opc_test_status=0
|
||||
dotnet test ZB.MOM.WW.OtOpcUa.slnx -c Release --no-build \
|
||||
-m:1 -p:TestTfmsInParallel=false --settings scripts/ci/ci.runsettings \
|
||||
--filter 'Category!=E2E&Category!=LiveIntegration' \
|
||||
--logger trx --results-directory "$opc_results" || opc_test_status=$?
|
||||
local opc_count_status=0
|
||||
MIN_EXECUTED=12000 bash scripts/ci/assert-not-all-skipped.sh "$opc_results"/*.trx \
|
||||
|| opc_count_status=$?
|
||||
printf 'TRX directory: %s\n' "$opc_results"
|
||||
if [ "$opc_test_status" -ne 0 ]; then return "$opc_test_status"; fi
|
||||
return "$opc_count_status"
|
||||
}
|
||||
opc_solution_test
|
||||
```
|
||||
|
||||
Publish the Host project to a fresh operator-selected `$OPC_PUBLISH_DIR` using the
|
||||
approved `$OPC_RID` and deployment's self-contained choice. The Windows install
|
||||
expects `OtOpcUa.Host.exe`; [ServiceHosting.md](../ServiceHosting.md) also describes
|
||||
script-worker packaging. Example self-contained Windows artifact command:
|
||||
|
||||
```bash
|
||||
dotnet publish src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj \
|
||||
-c Release -r win-x64 --self-contained true -o "$OPC_PUBLISH_DIR"
|
||||
```
|
||||
|
||||
Repeat with the approved Linux RID/framework settings in its validation environment;
|
||||
cross-publishing is not runtime/install evidence. Hash the complete staged artifact
|
||||
and retain a manifest outside it (PowerShell 7, where `$Candidate` and `$Manifest`
|
||||
are approved absolute local paths and the manifest is outside the candidate):
|
||||
|
||||
```powershell
|
||||
Get-ChildItem -LiteralPath $Candidate -Recurse -File |
|
||||
Get-FileHash -Algorithm SHA256 |
|
||||
Export-Csv -LiteralPath $Manifest -NoTypeInformation
|
||||
```
|
||||
|
||||
## Sequence and stop/go decision
|
||||
|
||||
Progress only after the preceding stage's recorded checks and explicit gate decision:
|
||||
**isolated rig → ScadaBridge/upstream interoperability → DEV canary → QA pair →
|
||||
production canary**. Attach source/artifact identity, start/end times, operator,
|
||||
checks, observations and go/hold/rollback decision for each stage to #782.
|
||||
|
||||
A standby-first pair restart is allowed only after the application's mixed-version
|
||||
Akka serialization, deployment/config and failover gate succeeds. Verify standby
|
||||
health, security, data, history and events; exercise failover before upgrading its
|
||||
peer. OPC UA wire interoperability alone does not prove cluster rolling safety.
|
||||
Without that gate, arrange a maintenance window and coordinated pair restart;
|
||||
record expected service interruption and recovery checks.
|
||||
|
||||
The following Windows commands are **local-session steps only**. Bind `$Install`
|
||||
to the inspected current binary directory, `$Candidate` to the reviewed staged
|
||||
artifact and `$Backup` to a new protected directory. Keep PKI/data stores outside
|
||||
binary replacement; if they are inside the install tree, define exclusions and a
|
||||
separate protected backup before using these commands. Save configuration outside
|
||||
`$Candidate`, then apply the reviewed node configuration to the staging directory;
|
||||
never overwrite it with publisher defaults or expose secrets in command output.
|
||||
|
||||
```powershell
|
||||
Get-CimInstance Win32_Service -Filter "Name='OtOpcUaHost'" |
|
||||
Select-Object Name, State, PathName, StartName
|
||||
Stop-Service -Name OtOpcUaHost -ErrorAction Stop
|
||||
(Get-Service -Name OtOpcUaHost).WaitForStatus('Stopped', [TimeSpan]::FromSeconds(60))
|
||||
robocopy $Install $Backup /E /COPYALL /DCOPY:DAT /R:1 /W:1
|
||||
if ($LASTEXITCODE -ge 8) { throw 'Known-good backup failed' }
|
||||
# After checking the backup and candidate manifest, swap complete binary directories.
|
||||
# Use sibling paths on the same volume; the service PathName must still resolve to $Install.
|
||||
Move-Item -LiteralPath $Install -Destination $PreviousInstall -ErrorAction Stop
|
||||
Move-Item -LiteralPath $Candidate -Destination $Install -ErrorAction Stop
|
||||
Start-Service -Name OtOpcUaHost -ErrorAction Stop
|
||||
(Get-Service -Name OtOpcUaHost).WaitForStatus('Running', [TimeSpan]::FromSeconds(60))
|
||||
```
|
||||
|
||||
`$PreviousInstall` must be a fresh reviewed sibling path. Handle any failure between
|
||||
moves by restoring that complete directory before starting. Service `Running` is
|
||||
only a process check. Run the substantive checks below after every start and failover:
|
||||
|
||||
- Check `/healthz`, `/health/ready` and role-appropriate `/health/active` at the
|
||||
recorded HTTP base URL; compare ServiceLevel and active/standby state to baseline.
|
||||
- Confirm certificate thumbprints, trust/issuer chains, endpoint policies/application
|
||||
URI and namespace-URI/NodeId identity. Prove trusted SignAndEncrypt works and
|
||||
untrusted/revoked/inappropriate certificates and wrong keys fail without downgrade.
|
||||
- Exercise raw and UNS browse/read, scalar/array writes on sandbox nodes, subscriptions,
|
||||
quality and source/server timestamps. Verify current legitimate ACL allows as well
|
||||
as denies, key expiry/revocation and audience-specific event/audit filtering.
|
||||
- Verify alarms/refresh/ack and no missing or duplicate events; verify configured raw,
|
||||
modified, processed and event history, paging/release/reauthentication and expected
|
||||
unsupported operations. Verify recorder/outbox progress without duplicate replay.
|
||||
- Observe reconnects, stale values, legitimate-call denials, continuation-point errors,
|
||||
session/item/certificate-handle growth, shutdown, queue depths and latency.
|
||||
|
||||
Use [Client.CLI.md](../Client.CLI.md) for exact connect/browse/read/subscribe/history
|
||||
and alarm syntax. Supply credentials through the approved operator mechanism; do
|
||||
not record command lines containing API-key passwords in release evidence.
|
||||
|
||||
Stop promotion and restore the known-good application for any authorization regression,
|
||||
missing/duplicate alarm or history data, changed identifiers, insecure fallback,
|
||||
restart failure or sustained performance-budget miss. Compare like hardware/config/load:
|
||||
at least three warmed measurements, provisional limits ≤10% median throughput loss
|
||||
and ≤15% p95 latency/allocation/RSS growth, plus at least two hours of lifecycle soak.
|
||||
Any deviation needs investigation and a recorded acceptance decision. Do not lower
|
||||
thresholds or count absent environments as passed.
|
||||
|
||||
## Rollback
|
||||
|
||||
1. Freeze promotion; record the trigger and affected nodes/clients in #782. Preserve
|
||||
candidate logs and manifests with secrets redacted. Stop the candidate through the
|
||||
approved local channel; the remote-exec hold still applies during rollback.
|
||||
2. With the service stopped, move the candidate install to a fresh quarantine path,
|
||||
restore the complete `$PreviousInstall` (or verified `$Backup`) to the inspected
|
||||
service path, and restore the matching node configuration and preserved PKI with
|
||||
original permissions. Check hashes/thumbprints before starting. Do not delete or
|
||||
roll back live historian, outbox or ConfigDb data without a reviewed data strategy.
|
||||
3. Start `OtOpcUaHost` locally with the same `Start-Service`/wait commands above.
|
||||
On Linux, use only the environment's approved recorded supervisor procedure.
|
||||
Verify health, endpoint/namespace/certificate identity and active/standby state.
|
||||
4. Reconnect clients as needed. In-memory sessions, subscriptions and continuation
|
||||
points do not survive process rollback; discard old points and begin new reads.
|
||||
Rebind namespaces by URI. Never replay ambiguous non-idempotent writes or alarm
|
||||
acknowledgements. Confirm API-key writes/ack, subscriptions, alarm counts and history
|
||||
continuity with the same positive and negative checks used at rollout.
|
||||
5. Record recovery time, checks, data reconciliation and final hold/go decision.
|
||||
If rolling source back, revert package and application commits together; never
|
||||
mix old package binaries with SDK 2 application assemblies. Keep candidate evidence
|
||||
for diagnosis and link follow-up issues from #782.
|
||||
|
||||
## Results handoff
|
||||
|
||||
Pending: coordinator to attach the final SHA, artifact hashes, TRX counts/skips,
|
||||
interoperability/performance/runtime evidence, reviews and per-environment operator
|
||||
records in #782. This document makes no claim of a passing build, test, ScadaBridge,
|
||||
Windows/Linux smoke, live backend, soak or deployment. Required inaccessible steps
|
||||
remain open gates unless formally separated into linked issues with accurate scope.
|
||||
@@ -36,6 +36,37 @@ Transport security and OPC UA authentication are per-node concerns configured in
|
||||
|
||||
## Transport Security
|
||||
|
||||
### SDK 2.0.1 compatibility (#782)
|
||||
|
||||
The application targets exact OPC Foundation SDK **2.0.1**, replacing **1.5.378.182**.
|
||||
The upgrade retains the existing host, security profiles, anonymous-admission setting,
|
||||
API-key authentication and ACL flag semantics. It adds no companion-specification or
|
||||
PubSub capability. Release evidence and operator gates are linked from the
|
||||
[rollout runbook](plans/2026-11-10-opcua-sdk-2-rollout.md); source changes alone do not establish release approval.
|
||||
|
||||
SDK role augmentation preserves the application's principal, API-key id and application
|
||||
roles. A rejected API key must remain rejected rather than falling through to another
|
||||
identity provider. Per-IP authentication miss accounting uses the public transport-observed
|
||||
`SecureChannelContext.PeerAddress` during activation; an unknown peer retains the existing
|
||||
process-wide budget fallback. This is accounting evidence, not a client-supplied address or
|
||||
a new IP admission policy.
|
||||
|
||||
SDK event receive checks compose with application ACL filtering. For the application's
|
||||
rejected-write `AuditWriteUpdateEvent` only, a temporary validation context supplies
|
||||
`ReceiveEvents` to the recipient's SDK roles after the current source variable, effective
|
||||
identity, source `Read` grant and audit/encryption guard are checked. It expires before
|
||||
queueing; shared role-permission metadata is not widened. Prior permission callback
|
||||
errors veto delivery. SDK-generated audits retain SDK defaults. This does not enable
|
||||
auditing or grant a general audit feed.
|
||||
|
||||
Certificate configuration uses the SDK certificate manager, and certificate ownership
|
||||
lasts through the owning host/session's lifetime. Preserve the existing application
|
||||
certificate, private key, issuer/peer stores and filesystem permissions during rollout;
|
||||
verify key access and CA usage under the service identity. Do not rotate certificates,
|
||||
enable auto-accept, add `None`, or relax revocation checks to get the candidate running.
|
||||
Continuation points remain session-owned and bound to node/topology and current authority;
|
||||
restart requires fresh sessions and fresh points.
|
||||
|
||||
### Overview
|
||||
|
||||
The OtOpcUa Server supports configurable OPC UA transport security profiles that control how data is protected on the wire between OPC UA clients and the server.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><OutputType>Exe</OutputType></PropertyGroup>
|
||||
<ItemGroup><PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.378.182" /></ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,4 @@
|
||||
<Project>
|
||||
<!-- Deliberately isolated baseline process: never inherit or load the candidate SDK. -->
|
||||
<PropertyGroup><TargetFramework>net10.0</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings><TreatWarningsAsErrors>true</TreatWarningsAsErrors><ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally></PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1 @@
|
||||
<Project><PropertyGroup><ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally></PropertyGroup></Project>
|
||||
@@ -0,0 +1,242 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
|
||||
if (args.Length != 1) throw new ArgumentException("Pass a fresh output JSON path.");
|
||||
if (File.Exists(args[0])) throw new IOException("Refusing to overwrite existing baseline evidence.");
|
||||
var epoch = new DateTime(2026, 10, 1, 8, 0, 0, DateTimeKind.Utc).AddTicks(1234567);
|
||||
var telemetry = DefaultTelemetry.Create(_ => { });
|
||||
var offsets = new double[] { -10, 0, 5, 15, 25, 35, 50, 80 };
|
||||
var data = new Dictionary<string, Sample[]>
|
||||
{
|
||||
["double"] = offsets.Select((s, i) => new Sample(s, "Double", (10 + i * 7 % 13).ToString(CultureInfo.InvariantCulture), StatusCodes.Good)).ToArray(),
|
||||
["uncertain"] = offsets.Select((s, i) => new Sample(s, i == 4 ? "Null" : "Double", i == 4 ? null : i == 2 ? "100" : (10 + i * 7 % 13).ToString(CultureInfo.InvariantCulture), i == 2 ? StatusCodes.UncertainDataSubNormal : i == 4 ? StatusCodes.BadNoCommunication : StatusCodes.Good)).ToArray(),
|
||||
["integer"] = offsets.Select((s, i) => new Sample(s, "Int64", (i * 7 - 10).ToString(CultureInfo.InvariantCulture), i == 3 ? StatusCodes.UncertainDataSubNormal : StatusCodes.Good)).ToArray(),
|
||||
["boolean"] = offsets.Select((s, i) => new Sample(s, "Boolean", (i % 2 == 0).ToString(), i == 2 ? StatusCodes.UncertainDataSubNormal : StatusCodes.Good)).ToArray(),
|
||||
["sparse"] = [new(-10, "Double", "10", StatusCodes.Good), new(7, "Double", "20", StatusCodes.Good), new(18, "Null", null, StatusCodes.BadNoCommunication), new(45, "Double", "30", StatusCodes.Good)],
|
||||
["bad"] = offsets.Select(s => new Sample(s, "Null", null, StatusCodes.BadNoCommunication)).ToArray(),
|
||||
["empty"] = [],
|
||||
};
|
||||
var names = new string[]
|
||||
{
|
||||
"AggregateFunction_Interpolative",
|
||||
"AggregateFunction_Average",
|
||||
"AggregateFunction_TimeAverage",
|
||||
"AggregateFunction_TimeAverage2",
|
||||
"AggregateFunction_Total",
|
||||
"AggregateFunction_Total2",
|
||||
"AggregateFunction_Minimum",
|
||||
"AggregateFunction_Maximum",
|
||||
"AggregateFunction_MinimumActualTime",
|
||||
"AggregateFunction_MaximumActualTime",
|
||||
"AggregateFunction_Range",
|
||||
"AggregateFunction_Minimum2",
|
||||
"AggregateFunction_Maximum2",
|
||||
"AggregateFunction_MinimumActualTime2",
|
||||
"AggregateFunction_MaximumActualTime2",
|
||||
"AggregateFunction_Range2",
|
||||
"AggregateFunction_Count",
|
||||
"AggregateFunction_AnnotationCount",
|
||||
"AggregateFunction_DurationInStateZero",
|
||||
"AggregateFunction_DurationInStateNonZero",
|
||||
"AggregateFunction_NumberOfTransitions",
|
||||
"AggregateFunction_Start",
|
||||
"AggregateFunction_End",
|
||||
"AggregateFunction_Delta",
|
||||
"AggregateFunction_StartBound",
|
||||
"AggregateFunction_EndBound",
|
||||
"AggregateFunction_DeltaBounds",
|
||||
"AggregateFunction_DurationGood",
|
||||
"AggregateFunction_DurationBad",
|
||||
"AggregateFunction_PercentGood",
|
||||
"AggregateFunction_PercentBad",
|
||||
"AggregateFunction_WorstQuality",
|
||||
"AggregateFunction_WorstQuality2",
|
||||
"AggregateFunction_StandardDeviationPopulation",
|
||||
"AggregateFunction_VariancePopulation",
|
||||
"AggregateFunction_StandardDeviationSample",
|
||||
"AggregateFunction_VarianceSample",
|
||||
};
|
||||
var vectors = new List<Vector>();
|
||||
// Preserve the original seven-dataset grid, input ordering and expected values exactly.
|
||||
foreach (var name in names)
|
||||
foreach (var dataset in data)
|
||||
foreach (var reverse in new[] { false, true })
|
||||
foreach (var stepped in new[] { false, true })
|
||||
foreach (var uncertainAsBad in new[] { false, true })
|
||||
foreach (var incremental in new[] { false, true })
|
||||
Run(name, dataset.Key, reverse, stepped, uncertainAsBad, incremental);
|
||||
|
||||
var edges = new Dictionary<string, Sample[]>
|
||||
{
|
||||
["edge-sbyte"] = Typed("SByte", ["-128", "-127", "-1", "0", "1", "126", "127", "2"]),
|
||||
["edge-byte"] = Typed("Byte", ["0", "1", "127", "128", "254", "255", "3", "2"]),
|
||||
["edge-int16"] = Typed("Int16", ["-32768", "-32767", "-1", "0", "1", "32766", "32767", "2"]),
|
||||
["edge-uint16"] = Typed("UInt16", ["0", "1", "32767", "32768", "65534", "65535", "3", "2"]),
|
||||
["edge-int32"] = Typed("Int32", ["-2147483648", "-2147483647", "-1", "0", "1", "2147483646", "2147483647", "2"]),
|
||||
["edge-uint32"] = Typed("UInt32", ["0", "1", "2147483647", "2147483648", "4294967294", "4294967295", "3", "2"]),
|
||||
["edge-int64"] = Typed("Int64", ["-9223372036854775808", "-9007199254740993", "-1", "0", "1", "9007199254740993", "9223372036854775806", "9223372036854775807"]),
|
||||
["edge-uint64"] = Typed("UInt64", ["0", "1", "9007199254740991", "9007199254740993", "9223372036854775807", "9223372036854775808", "18446744073709551614", "18446744073709551615"]),
|
||||
["edge-float"] = Typed("Single", ["-3.4028235E+38", "1E-45", "-0", "1.5", "3.4028235E+38", "NaN", "Infinity", "-Infinity"]),
|
||||
["edge-double"] = Typed("Double", ["-1.5", "-0", "0.5", "1.5", "NaN", "Infinity", "-Infinity", "1.7976931348623157E+308"]),
|
||||
// Interpolative/Delta casts back to the earlier sample's type: fractional, negative,
|
||||
// overflowing and nonfinite Double values must exercise the original SDK conversion.
|
||||
["edge-mixed-conversion"] =
|
||||
[
|
||||
new(-10, "Byte", "0", StatusCodes.Good), new(0, "Byte", "1", StatusCodes.Good),
|
||||
new(5, "Double", "2.5", StatusCodes.Good), new(15, "SByte", "-128", StatusCodes.Good),
|
||||
new(25, "Double", "255.5", StatusCodes.Good), new(35, "Single", "3.4028235E+38", StatusCodes.Good),
|
||||
new(50, "Double", "Infinity", StatusCodes.Good), new(80, "UInt64", "18446744073709551615", StatusCodes.Good),
|
||||
],
|
||||
};
|
||||
foreach (var dataset in edges) data.Add(dataset.Key, dataset.Value);
|
||||
foreach (var name in names)
|
||||
foreach (var dataset in edges)
|
||||
foreach (var reverse in new[] { false, true })
|
||||
foreach (var incremental in new[] { false, true })
|
||||
Run(name, dataset.Key, reverse, false, false, incremental, Settings(false));
|
||||
|
||||
// Ties include an identical source timestamp+picoseconds pair; input order is stable.
|
||||
// Distinct server ticks and picoseconds must survive raw-result selection independently.
|
||||
data.Add("edge-duplicate-picoseconds",
|
||||
[
|
||||
Pico(-10, "1", 3, 19, 7), Pico(0, "2", 1, 23, 11), Pico(0, "3", 9999, 29, 13),
|
||||
Pico(5, "4", 7, 31, 17), Pico(5, "5", 7, 37, 19), Pico(20, "6", 2, 41, 23),
|
||||
Pico(20, "7", 9000, 43, 29), Pico(40, "8", 5, 47, 31), Pico(80, "9", 11, 53, 37),
|
||||
]);
|
||||
foreach (var name in names)
|
||||
foreach (var reverse in new[] { false, true })
|
||||
foreach (var incremental in new[] { false, true })
|
||||
Run(name, "edge-duplicate-picoseconds", reverse, false, false, incremental, Settings(false));
|
||||
|
||||
var configurations = new[]
|
||||
{
|
||||
new AggregateSettings(false, 0, 0, false, false),
|
||||
new AggregateSettings(false, 1, 99, true, false),
|
||||
new AggregateSettings(true, 50, 50, false, false),
|
||||
new AggregateSettings(true, 100, 0, true, false),
|
||||
};
|
||||
var configuredNames = new[]
|
||||
{
|
||||
"AggregateFunction_Interpolative", "AggregateFunction_TimeAverage", "AggregateFunction_TimeAverage2",
|
||||
"AggregateFunction_Total", "AggregateFunction_Total2", "AggregateFunction_PercentGood", "AggregateFunction_PercentBad",
|
||||
};
|
||||
foreach (var name in configuredNames)
|
||||
foreach (var dataset in new[] { "uncertain", "sparse" })
|
||||
foreach (var config in configurations)
|
||||
foreach (var reverse in new[] { false, true })
|
||||
foreach (var incremental in new[] { false, true })
|
||||
Run(name, dataset, reverse, false, config.TreatUncertainAsBad, incremental, config);
|
||||
|
||||
var assembly = typeof(Aggregators).Assembly.Location;
|
||||
var assemblySha256 = Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(assembly)));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(args[0]))!);
|
||||
// One vector per line keeps golden changes reviewable without pretty-printing every value.
|
||||
using (var writer = new StreamWriter(new FileStream(args[0], FileMode.CreateNew, FileAccess.Write)))
|
||||
{
|
||||
writer.WriteLine("{");
|
||||
writer.WriteLine($" \"sdk\":\"1.5.378.182\",\"assemblySha256\":\"{assemblySha256}\",");
|
||||
writer.WriteLine($" \"epochTicks\":{epoch.Ticks},\"intervalMilliseconds\":20000,");
|
||||
writer.WriteLine($" \"data\":{JsonSerializer.Serialize(data)},");
|
||||
writer.WriteLine(" \"vectors\":[");
|
||||
for (var i = 0; i < vectors.Count; i++)
|
||||
writer.WriteLine(" " + JsonSerializer.Serialize(vectors[i]) + (i + 1 < vectors.Count ? "," : ""));
|
||||
writer.WriteLine(" ]");
|
||||
writer.WriteLine("}");
|
||||
}
|
||||
Console.WriteLine($"Generated {vectors.Count} vectors ({vectors.Count - 4144} focused additions) for {names.Length} aggregate IDs; baseline assembly SHA256 {assemblySha256}.");
|
||||
|
||||
Sample[] Typed(string type, string[] values) => offsets.Zip(values, (s, value) => new Sample(s, type, value, StatusCodes.Good)).ToArray();
|
||||
static Sample Pico(double seconds, string value, ushort source, ushort server, long serverOffset) =>
|
||||
new(seconds, "Int32", value, StatusCodes.Good)
|
||||
{ SourcePicoseconds = source, ServerPicoseconds = server, ServerOffsetTicks = serverOffset };
|
||||
static AggregateSettings Settings(bool uncertainAsBad) => new(uncertainAsBad, 100, 100, false, false);
|
||||
|
||||
void Run(string name, string dataset, bool reverse, bool stepped, bool uncertainAsBad, bool incremental,
|
||||
AggregateSettings? settings = null)
|
||||
{
|
||||
var id = (NodeId)typeof(ObjectIds).GetField(name)!.GetValue(null)!;
|
||||
var start = epoch.AddSeconds(reverse ? 70 : 0);
|
||||
var end = epoch.AddSeconds(reverse ? 0 : 70);
|
||||
var cfg = settings ?? Settings(uncertainAsBad);
|
||||
var config = new AggregateConfiguration
|
||||
{
|
||||
TreatUncertainAsBad = cfg.TreatUncertainAsBad, PercentDataBad = cfg.PercentDataBad,
|
||||
PercentDataGood = cfg.PercentDataGood, UseSlopedExtrapolation = cfg.UseSlopedExtrapolation,
|
||||
UseServerCapabilitiesDefaults = cfg.UseServerCapabilitiesDefaults,
|
||||
};
|
||||
var calculator = Aggregators.CreateStandardCalculator(id, start, end, 20_000, stepped, config, telemetry);
|
||||
var output = new List<Value>();
|
||||
var accepted = new List<bool>();
|
||||
string? error = null;
|
||||
try
|
||||
{
|
||||
var samples = data[dataset].OrderBy(x => x.Seconds);
|
||||
foreach (var sample in reverse ? samples.Reverse() : samples)
|
||||
{
|
||||
var time = epoch.AddSeconds(sample.Seconds);
|
||||
var value = new DataValue(new Variant(Payload(sample)!), sample.Status, time,
|
||||
time.AddTicks(sample.ServerOffsetTicks ?? 0))
|
||||
{
|
||||
SourcePicoseconds = sample.SourcePicoseconds ?? 0,
|
||||
ServerPicoseconds = sample.ServerPicoseconds ?? 0,
|
||||
};
|
||||
accepted.Add(calculator.QueueRawValue(value));
|
||||
if (incremental) Drain(false);
|
||||
}
|
||||
Drain(true);
|
||||
}
|
||||
catch (Exception ex) { error = ex.GetType().FullName; }
|
||||
vectors.Add(new(name, dataset, reverse, stepped, uncertainAsBad, incremental, output.ToArray(), error)
|
||||
{ Configuration = settings, QueueAccepted = settings is null ? null : accepted.ToArray() });
|
||||
void Drain(bool partial)
|
||||
{
|
||||
for (var guard = 0; guard < 100; guard++)
|
||||
{
|
||||
var value = calculator.GetProcessedValue(partial);
|
||||
if (value is null) return;
|
||||
output.Add(new(partial, value.Value?.GetType().Name, Format(value.Value), value.StatusCode.Code, value.SourceTimestamp.Ticks, value.ServerTimestamp.Ticks, value.SourcePicoseconds, value.ServerPicoseconds));
|
||||
}
|
||||
throw new InvalidOperationException("Baseline calculator did not terminate.");
|
||||
}
|
||||
}
|
||||
|
||||
static object? Payload(Sample sample) => sample.Type switch
|
||||
{
|
||||
"SByte" => sbyte.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
||||
"Byte" => byte.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
||||
"Int16" => short.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
||||
"UInt16" => ushort.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
||||
"Int32" => int.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
||||
"UInt32" => uint.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
||||
"Int64" => long.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
||||
"UInt64" => ulong.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
||||
"Single" => float.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
||||
"Double" => double.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
||||
"Boolean" => bool.Parse(sample.Payload!),
|
||||
_ => null,
|
||||
};
|
||||
static string? Format(object? value) => value switch
|
||||
{
|
||||
null => null,
|
||||
double number => number.ToString("R", CultureInfo.InvariantCulture),
|
||||
float number => number.ToString("R", CultureInfo.InvariantCulture),
|
||||
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
|
||||
_ => value.ToString(),
|
||||
};
|
||||
record Sample(double Seconds, string Type, string? Payload, uint Status)
|
||||
{
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ushort? SourcePicoseconds { get; init; }
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ushort? ServerPicoseconds { get; init; }
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public long? ServerOffsetTicks { get; init; }
|
||||
}
|
||||
record Value(bool PartialRequested, string? Type, string? Payload, uint Status, long SourceTicks, long ServerTicks, ushort SourcePicoseconds, ushort ServerPicoseconds);
|
||||
record AggregateSettings(bool TreatUncertainAsBad, byte PercentDataBad, byte PercentDataGood, bool UseSlopedExtrapolation, bool UseServerCapabilitiesDefaults);
|
||||
record Vector(string Aggregate, string DataSet, bool Reverse, bool Stepped, bool UncertainAsBad, bool Incremental, Value[] Output, string? Exception)
|
||||
{
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public AggregateSettings? Configuration { get; init; }
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool[]? QueueAccepted { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
# Original SDK aggregate oracle
|
||||
|
||||
This standalone .NET 10 process generates aggregate expectations from the published
|
||||
`OPCFoundation.NetStandard.Opc.Ua.Server` **1.5.378.182** package. Its local build and
|
||||
package properties isolate it from the application's SDK 2 graph. It is outside the
|
||||
solution; never add its baseline SDK assemblies to a candidate test or production process.
|
||||
Expectations must come from this original SDK, never from the compatibility calculators.
|
||||
|
||||
Run from the repository root with a fresh output path:
|
||||
|
||||
```bash
|
||||
dotnet run --project scripts/validation/opcua-aggregate-baseline/Baseline.csproj \
|
||||
-c Release -- /tmp/opcua-original-aggregates-new.json
|
||||
```
|
||||
|
||||
An existing output path is refused. The JSON records the original aggregate assembly's
|
||||
SHA-256, epoch and processing interval. The expected assembly hash is
|
||||
`d8d9706dea577858325730cd89896826ed82f7bfcf9503ccd0260311663a69f4`.
|
||||
Output uses compact metadata and data, followed by one vector per line for reviewable diffs.
|
||||
|
||||
The first **4,144 vectors** retain the original seven datasets and complete grid over 37
|
||||
aggregate IDs, forward/reverse reads, stepped/sloped interpolation, uncertain-as-bad and
|
||||
incremental/final processing. Their parsed objects and the seven original input datasets
|
||||
are unchanged. The request window is epoch +0 through +70 seconds, reversed for reverse
|
||||
vectors, with a 20,000 ms processing interval.
|
||||
|
||||
The next **2,000 focused vectors** avoid expanding every dataset into the complete grid:
|
||||
|
||||
| Cases | Coverage |
|
||||
|---:|---|
|
||||
| 1,628 | Eleven numeric datasets across all 37 IDs, both directions and both processing modes: all eight integer widths, Single, Double and mixed-type conversion. Includes narrow limits, Int64/UInt64 values around 2^53 and 2^63 and their full ranges, fractional interpolation, signed zero, nonfinite values and overflow. |
|
||||
| 148 | Duplicate timestamp ties across all 37 IDs, both directions and processing modes; nonzero source/server picoseconds, independent server timestamp offsets and an exact source timestamp/picosecond tie. |
|
||||
| 224 | Interpolative, TimeAverage/2, Total/2 and PercentGood/Bad over uncertain and sparse data, both directions and processing modes. Four explicit profiles cover PercentDataBad/Good 0/0, 1/99, 50/50 and 100/0, both uncertainty policies and sloped extrapolation. |
|
||||
|
||||
New vectors use sloped interpolation (`Stepped=false`); their `Configuration` object records
|
||||
all five effective aggregate settings. `UseServerCapabilitiesDefaults=false` records the
|
||||
resolved configuration passed directly to the calculator. Existing vectors retain their
|
||||
original fields: omitted configuration means the vector's uncertainty policy, 100/100
|
||||
thresholds, and both extrapolation and server-capability defaults disabled.
|
||||
|
||||
New samples may carry `SourcePicoseconds`, `ServerPicoseconds` and `ServerOffsetTicks`;
|
||||
absent fields mean zero. The server timestamp is epoch + `Seconds` + `ServerOffsetTicks`.
|
||||
Samples are sorted stably by `Seconds`, then the whole sequence is reversed for a reverse
|
||||
read, preserving a fully specified tie order. `QueueAccepted` records each attempted
|
||||
queue result through the point of any exception.
|
||||
|
||||
Each output retains its CLR type, round-trip value spelling, complete numeric status,
|
||||
source/server timestamp ticks, source/server picoseconds, and whether it came from an
|
||||
incremental (`PartialRequested=false`) or final (`true`) drain. `Exception` retains the
|
||||
exact original exception type and any outputs already emitted. NaN, infinities and signed
|
||||
zero are stored as strings so JSON does not change their numeric representation.
|
||||
|
||||
When importing fresh evidence into the candidate fixture, first compare the original
|
||||
4,144 parsed vectors, seven datasets and metadata with the previous oracle. Investigate
|
||||
candidate mismatches; do not regenerate expectations from candidate output or adjust
|
||||
original exception types to make the comparison pass.
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||
using System.Globalization;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Exceptions;
|
||||
@@ -210,7 +211,7 @@ public class HistoryReadCommand : CommandBase
|
||||
: StatusCode.IsBad(dv.StatusCode) ? "Bad"
|
||||
: "Uncertain";
|
||||
await console.Output.WriteLineAsync(
|
||||
$"{dv.SourceTimestamp.ToString("O"),-35} {dv.Value?.ToString() ?? "(none)",-15} {status,-10} " +
|
||||
$"{dv.SourceTimestamp.ToString("O"),-35} {ValueConverter.ToClrObject(dv.WrappedValue)?.ToString() ?? "(none)",-15} {status,-10} " +
|
||||
$"{e.UpdateType,-8} {e.UserName ?? "",-20} {e.ModificationTime:O}");
|
||||
}
|
||||
|
||||
@@ -244,7 +245,7 @@ public class HistoryReadCommand : CommandBase
|
||||
: StatusCode.IsBad(dv.StatusCode) ? "Bad"
|
||||
: "Uncertain";
|
||||
await console.Output.WriteLineAsync(
|
||||
$"{dv.SourceTimestamp.ToString("O"),-35} {dv.Value,-15} {status}");
|
||||
$"{dv.SourceTimestamp.ToString("O"),-35} {ValueConverter.ToClrObject(dv.WrappedValue),-15} {status}");
|
||||
}
|
||||
|
||||
await console.Output.WriteLineAsync();
|
||||
@@ -276,23 +277,25 @@ public class HistoryReadCommand : CommandBase
|
||||
internal static EventFilter BuildEventFilter(IReadOnlyList<string> fields, NodeId? ofType)
|
||||
{
|
||||
var filter = new EventFilter();
|
||||
var clauses = new List<SimpleAttributeOperand>();
|
||||
foreach (var field in fields)
|
||||
{
|
||||
var type = string.Equals(field, "ConditionName", StringComparison.Ordinal)
|
||||
? ObjectTypeIds.ConditionType
|
||||
: ObjectTypeIds.BaseEventType;
|
||||
filter.SelectClauses.Add(new SimpleAttributeOperand(type, new QualifiedName(field)) { AttributeId = Attributes.Value });
|
||||
clauses.Add(new SimpleAttributeOperand(type, new QualifiedName(field)) { AttributeId = Attributes.Value });
|
||||
}
|
||||
|
||||
filter.SelectClauses = clauses.ToArrayOf();
|
||||
if (ofType is not null)
|
||||
filter.WhereClause.Push(FilterOperator.OfType, new LiteralOperand(new Variant(ofType)));
|
||||
filter.WhereClause.Push(FilterOperator.OfType, Variant.From(ofType.Value));
|
||||
return filter;
|
||||
}
|
||||
|
||||
/// <summary>Formats one projected event field for the console: byte strings as hex, times as ISO 8601, null as <c>-</c>.</summary>
|
||||
/// <param name="field">The field value.</param>
|
||||
/// <returns>Its display text.</returns>
|
||||
private static string FormatEventField(Variant field) => field.Value switch
|
||||
private static string FormatEventField(Variant field) => ValueConverter.ToClrObject(field) switch
|
||||
{
|
||||
null => "-",
|
||||
byte[] bytes => Convert.ToHexString(bytes),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||
using System.Globalization;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Exceptions;
|
||||
@@ -89,11 +90,11 @@ public class HistoryUpdateCommand : CommandBase
|
||||
throw new CommandException(
|
||||
$"--value and --time must be supplied in pairs ({values.Count} value(s), {times.Count} time(s)).");
|
||||
|
||||
var updates = new DataValueCollection();
|
||||
var updates = new List<DataValue>();
|
||||
for (var i = 0; i < values.Count; i++)
|
||||
{
|
||||
var ts = ParseTime(times[i], "--time");
|
||||
updates.Add(new DataValue(new Variant(ParseValue(values[i], Type)), StatusCodes.Good, ts, ts));
|
||||
updates.Add(new DataValue(ValueConverter.ToVariant(ParseValue(values[i], Type)), StatusCodes.Good, ts, ts));
|
||||
}
|
||||
|
||||
return new UpdateDataDetails
|
||||
@@ -105,7 +106,7 @@ public class HistoryUpdateCommand : CommandBase
|
||||
"replace" => PerformUpdateType.Replace,
|
||||
_ => PerformUpdateType.Update
|
||||
},
|
||||
UpdateValues = updates
|
||||
UpdateValues = updates.ToArrayOf()
|
||||
};
|
||||
}
|
||||
case "delete-raw":
|
||||
@@ -134,9 +135,9 @@ public class HistoryUpdateCommand : CommandBase
|
||||
throw new CommandException("--mode delete-at-time takes --time only; --value is not allowed.");
|
||||
if (times.Count == 0)
|
||||
throw new CommandException("--mode delete-at-time requires at least one --time.");
|
||||
var req = new DateTimeCollection();
|
||||
var req = new List<DateTimeUtc>();
|
||||
foreach (var t in times) req.Add(ParseTime(t, "--time"));
|
||||
return new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = req };
|
||||
return new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = req.ToArrayOf() };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -238,8 +239,8 @@ public class HistoryUpdateCommand : CommandBase
|
||||
/// <returns>The status name, or <c>0xXXXXXXXX</c>.</returns>
|
||||
private static string StatusName(StatusCode code)
|
||||
{
|
||||
var name = StatusCodes.GetBrowseName(code.Code);
|
||||
if (string.IsNullOrEmpty(name)) name = StatusCodes.GetBrowseName(code.Code & 0xFFFF0000u);
|
||||
var name = code.SymbolicId;
|
||||
if (string.IsNullOrEmpty(name)) name = new StatusCode(code.Code & 0xFFFF0000u).SymbolicId;
|
||||
return string.IsNullOrEmpty(name) ? $"0x{code.Code:X8}" : name;
|
||||
}
|
||||
|
||||
@@ -247,8 +248,8 @@ public class HistoryUpdateCommand : CommandBase
|
||||
{
|
||||
return details switch
|
||||
{
|
||||
UpdateDataDetails u => u.UpdateValues.Select(v => v.SourceTimestamp.ToString("O")).ToList(),
|
||||
DeleteAtTimeDetails d => d.ReqTimes.Select(t => t.ToString("O")).ToList(),
|
||||
UpdateDataDetails u => (u.UpdateValues.ToArray() ?? []).Select(v => v.SourceTimestamp.ToString("O")).ToList(),
|
||||
DeleteAtTimeDetails d => (d.ReqTimes.ToArray() ?? []).Select(t => t.ToString("O")).ToList(),
|
||||
DeleteRawModifiedDetails r => [$"{r.StartTime:O}..{r.EndTime:O}"],
|
||||
_ => []
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Exceptions;
|
||||
using CliFx.Infrastructure;
|
||||
@@ -59,12 +60,12 @@ public class ReadCommand : CommandBase
|
||||
// not crash with a stack trace — session-level failures still propagate.
|
||||
await console.Output.WriteLineAsync($"Node: {NodeId}");
|
||||
await console.Output.WriteLineAsync($"Value: ");
|
||||
await console.Output.WriteLineAsync($"Status: {(StatusCode)ex.StatusCode} ({StatusCodes.GetBrowseName(ex.StatusCode)})");
|
||||
await console.Output.WriteLineAsync($"Status: {(StatusCode)ex.StatusCode} ({ex.StatusCode.SymbolicId})");
|
||||
return;
|
||||
}
|
||||
|
||||
await console.Output.WriteLineAsync($"Node: {NodeId}");
|
||||
await console.Output.WriteLineAsync($"Value: {value.Value}");
|
||||
await console.Output.WriteLineAsync($"Value: {ValueConverter.ToClrObject(value.WrappedValue)}");
|
||||
await console.Output.WriteLineAsync($"Status: {value.StatusCode}");
|
||||
await console.Output.WriteLineAsync($"Source Time: {value.SourceTimestamp:O}");
|
||||
await console.Output.WriteLineAsync($"Server Time: {value.ServerTimestamp:O}");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading.Channels;
|
||||
using CliFx.Attributes;
|
||||
@@ -123,14 +124,14 @@ public class SubscribeCommand : CommandBase
|
||||
try
|
||||
{
|
||||
var key = e.NodeId.ToString();
|
||||
lastStatus[key] = (e.Value.StatusCode, DateTime.UtcNow, e.Value.Value);
|
||||
lastStatus[key] = (e.Value.StatusCode, DateTime.UtcNow, ValueConverter.ToClrObject(e.Value.WrappedValue));
|
||||
updateCount.AddOrUpdate(key, 1, (_, v) => v + 1);
|
||||
if (!StatusCode.IsGood(e.Value.StatusCode))
|
||||
everBad.TryAdd(key, 0);
|
||||
if (!Quiet)
|
||||
{
|
||||
var line =
|
||||
$"[{e.Value.SourceTimestamp:O}] {displayNameByNodeId.GetValueOrDefault(key, key)} = {e.Value.Value} ({e.Value.StatusCode})";
|
||||
$"[{e.Value.SourceTimestamp:O}] {displayNameByNodeId.GetValueOrDefault(key, key)} = {ValueConverter.ToClrObject(e.Value.WrappedValue)} ({e.Value.StatusCode})";
|
||||
outputChannel.Writer.TryWrite(line);
|
||||
}
|
||||
}
|
||||
@@ -271,7 +272,7 @@ public class SubscribeCommand : CommandBase
|
||||
var childPath = $"{parentPath}/{child.DisplayName}";
|
||||
if (child.NodeClass == "Variable")
|
||||
{
|
||||
into.Add((nodeId, childPath));
|
||||
into.Add((nodeId.Value, childPath));
|
||||
}
|
||||
if (child.HasChildren)
|
||||
{
|
||||
|
||||
@@ -54,7 +54,7 @@ public class WriteCommand : CommandBase
|
||||
|
||||
// Read current value to determine type for conversion
|
||||
var currentValue = await service.ReadValueAsync(nodeId, ct);
|
||||
var typedValue = ValueConverter.ConvertValue(Value, currentValue.Value);
|
||||
var typedValue = ValueConverter.ConvertValue(Value, ValueConverter.ToClrObject(currentValue.WrappedValue));
|
||||
|
||||
var statusCode = await service.WriteValueAsync(nodeId, typedValue, ct);
|
||||
|
||||
|
||||
@@ -52,6 +52,6 @@ public static class NodeIdParser
|
||||
var result = Parse(nodeIdString);
|
||||
if (result == null)
|
||||
throw new ArgumentException("Node ID is required but was not provided.");
|
||||
return result;
|
||||
return result.Value;
|
||||
}
|
||||
}
|
||||
+21
-14
@@ -23,7 +23,7 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
|
||||
? ClientStoragePaths.GetPkiPath()
|
||||
: settings.CertificateStorePath;
|
||||
|
||||
var config = new ApplicationConfiguration
|
||||
var config = new ApplicationConfiguration(ClientTelemetry.Context)
|
||||
{
|
||||
ApplicationName = "OtOpcUaClient",
|
||||
ApplicationUri = "urn:localhost:OtOpcUaClient",
|
||||
@@ -33,7 +33,8 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
|
||||
ApplicationCertificate = new CertificateIdentifier
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(storePath, "own")
|
||||
StorePath = Path.Combine(storePath, "own"),
|
||||
SubjectName = "CN=OtOpcUaClient"
|
||||
},
|
||||
TrustedIssuerCertificates = new CertificateTrustList
|
||||
{
|
||||
@@ -58,20 +59,26 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
|
||||
}
|
||||
};
|
||||
|
||||
await config.ValidateAsync(ApplicationType.Client, ct);
|
||||
|
||||
if (settings.AutoAcceptCertificates)
|
||||
config.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
if (settings.SecurityMode != SecurityMode.None)
|
||||
try
|
||||
{
|
||||
var app = new ApplicationInstance(ClientTelemetry.Context)
|
||||
await config.ValidateAsync(ApplicationType.Client, ct);
|
||||
|
||||
if (settings.SecurityMode != SecurityMode.None)
|
||||
{
|
||||
ApplicationName = "OtOpcUaClient",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
ApplicationConfiguration = config
|
||||
};
|
||||
await app.CheckApplicationInstanceCertificatesAsync(false, 2048);
|
||||
var app = new ApplicationInstance(ClientTelemetry.Context)
|
||||
{
|
||||
ApplicationName = "OtOpcUaClient",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
ApplicationConfiguration = config
|
||||
};
|
||||
await app.CheckApplicationInstanceCertificatesAsync(false, lifeTimeInMonths: 2048, ct: ct);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { (config.CertificateManager as IDisposable)?.Dispose(); }
|
||||
catch (Exception ex) { Logger.Warning(ex, "Disposing certificate manager after configuration failure"); }
|
||||
throw;
|
||||
}
|
||||
|
||||
Logger.Debug("ApplicationConfiguration created for {EndpointUrl}", settings.EndpointUrl);
|
||||
|
||||
@@ -19,9 +19,9 @@ internal sealed class DefaultEndpointDiscovery : IEndpointDiscovery
|
||||
// obsolete synchronous CoreClientUtils.SelectEndpoint, which does the same filter + rewrite.)
|
||||
using var client = await DiscoveryClient.CreateAsync(
|
||||
config, new Uri(endpointUrl), DiagnosticsMasks.None, ct).ConfigureAwait(false);
|
||||
var allEndpoints = await client.GetEndpointsAsync(null, ct).ConfigureAwait(false);
|
||||
var allEndpoints = await client.GetEndpointsAsync(default, ct).ConfigureAwait(false);
|
||||
|
||||
return EndpointSelector.SelectBest(allEndpoints, endpointUrl, requestedMode);
|
||||
return EndpointSelector.SelectBest(allEndpoints.ToArray() ?? [], endpointUrl, requestedMode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ internal static class EndpointSelector
|
||||
// advertises its own host:port (docker-dev containers all say :4840); following that would
|
||||
// open the session on a different server and fail the secure-channel certificate check.
|
||||
// Only when the scheme matches: a different scheme (https vs opc.tcp) is a different transport.
|
||||
var serverUri = new Uri(best.EndpointUrl);
|
||||
var serverUri = new Uri(best.EndpointUrl ?? throw new InvalidOperationException("Endpoint has no URL."));
|
||||
var requestedUri = new Uri(endpointUrl);
|
||||
// A URL typed without a port (opc.tcp has no registered default, so Port is -1) keeps the advertised port.
|
||||
var port = requestedUri.Port > 0 ? requestedUri.Port : serverUri.Port;
|
||||
|
||||
@@ -12,21 +12,25 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext<DefaultSessionAdapter>();
|
||||
private readonly Session _session;
|
||||
private readonly IDisposable? _certificateManager;
|
||||
private int _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a live OPC UA session so the shared client can issue runtime operations through a testable adapter surface.
|
||||
/// </summary>
|
||||
/// <param name="session">The connected OPC UA session used for browsing, reads, writes, history, and subscriptions.</param>
|
||||
public DefaultSessionAdapter(Session session)
|
||||
/// <param name="certificateManager">The per-connection certificate manager owned until session disposal.</param>
|
||||
public DefaultSessionAdapter(Session session, IDisposable? certificateManager = null)
|
||||
{
|
||||
_session = session;
|
||||
_certificateManager = certificateManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Connected => _session.Connected;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string SessionId => _session.SessionId?.ToString() ?? string.Empty;
|
||||
public string SessionId => _session.SessionId.IsNull ? string.Empty : _session.SessionId.ToString();
|
||||
|
||||
/// <inheritdoc />
|
||||
public string SessionName => _session.SessionName ?? string.Empty;
|
||||
@@ -35,7 +39,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
public string EndpointUrl => _session.Endpoint?.EndpointUrl ?? string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ServerName => _session.Endpoint?.Server?.ApplicationName?.Text ?? string.Empty;
|
||||
public string ServerName => _session.Endpoint?.Server?.ApplicationName.Text ?? string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string SecurityMode => _session.Endpoint?.SecurityMode.ToString() ?? string.Empty;
|
||||
@@ -72,13 +76,13 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
Value = value
|
||||
};
|
||||
|
||||
var writeCollection = new WriteValueCollection { writeValue };
|
||||
ArrayOf<WriteValue> writeCollection = [writeValue];
|
||||
var response = await _session.WriteAsync(null, writeCollection, ct);
|
||||
// A malformed or service-level-faulted response can come back with an empty
|
||||
// Results collection alongside a service fault. Surface the service result
|
||||
// (or BadUnexpectedError) rather than letting Results[0] throw
|
||||
// IndexOutOfRangeException upstream.
|
||||
if (response.Results == null || response.Results.Count == 0)
|
||||
if (response.Results.Count == 0)
|
||||
{
|
||||
var serviceResult = response.ResponseHeader?.ServiceResult.Code ?? StatusCodes.BadUnexpectedError;
|
||||
throw new ServiceResultException(serviceResult,
|
||||
@@ -89,7 +93,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync(
|
||||
public async Task<(byte[]? ContinuationPoint, ArrayOf<ReferenceDescription> References)> BrowseAsync(
|
||||
NodeId nodeId, uint nodeClassMask, CancellationToken ct)
|
||||
{
|
||||
var (_, continuationPoint, references) = await _session.BrowseAsync(
|
||||
@@ -100,19 +104,25 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
BrowseDirection.Forward,
|
||||
ReferenceTypeIds.HierarchicalReferences,
|
||||
true,
|
||||
nodeClassMask);
|
||||
nodeClassMask, ct);
|
||||
|
||||
return (continuationPoint, references ?? []);
|
||||
return (continuationPoint.IsNull ? null : continuationPoint.ToArray(), references);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
|
||||
public async Task<(byte[]? ContinuationPoint, ArrayOf<ReferenceDescription> References)> BrowseNextAsync(
|
||||
byte[] continuationPoint, CancellationToken ct)
|
||||
{
|
||||
// Pass the caller's token so a cancelled browse does not block on the continuation
|
||||
// round-trip.
|
||||
var (_, nextCp, nextRefs) = await _session.BrowseNextAsync(null, false, continuationPoint, ct);
|
||||
return (nextCp, nextRefs ?? []);
|
||||
var (_, nextCp, nextRefs) = await _session.BrowseNextAsync(null, false, ByteString.From(continuationPoint), ct);
|
||||
return (nextCp.IsNull ? null : nextCp.ToArray(), nextRefs);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ReleaseBrowseContinuationPointAsync(byte[] continuationPoint, CancellationToken ct)
|
||||
{
|
||||
await _session.BrowseNextAsync(null, true, ByteString.From(continuationPoint), ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -120,16 +130,16 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
{
|
||||
var response = await _session.HistoryUpdateAsync(
|
||||
null,
|
||||
new ExtensionObjectCollection { new ExtensionObject(details) },
|
||||
[new ExtensionObject(details)],
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
var statuses = new List<StatusCode>();
|
||||
var ops = new List<IReadOnlyList<StatusCode>>();
|
||||
if (response.Results != null)
|
||||
if (!response.Results.IsEmpty)
|
||||
foreach (var r in response.Results)
|
||||
{
|
||||
statuses.Add(r.StatusCode);
|
||||
ops.Add(r.OperationResults?.ToArray() ?? Array.Empty<StatusCode>());
|
||||
ops.Add(r.OperationResults.ToArray() ?? []);
|
||||
}
|
||||
|
||||
return new Models.HistoryUpdateOutcome(response.ResponseHeader.ServiceResult, statuses, ops);
|
||||
@@ -152,10 +162,10 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
// by the cap releases the server's cursor (HistoryReadPaging). This path used to pass
|
||||
// `releaseContinuationPoints: continuationPoint != null`, which RELEASED the second page instead of reading it,
|
||||
// silently truncating every raw read to its first server page.
|
||||
return await HistoryReadPaging.ReadAllAsync(
|
||||
return await HistoryReadPaging.ReadAllAsync<DataValue>(
|
||||
(cp, release, token) => ReadHistoryPageAsync(details, nodeId, cp, release, TimestampsToReturn.Source, token),
|
||||
result => result.HistoryData is ExtensionObject { Body: HistoryData historyData }
|
||||
? historyData.DataValues
|
||||
result => result.HistoryData.TryGetValue(out HistoryData? historyData)
|
||||
? historyData.DataValues.ToArray() ?? []
|
||||
: [],
|
||||
maxValues, nodeId, ct).ConfigureAwait(false);
|
||||
}
|
||||
@@ -193,21 +203,21 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
|
||||
return await HistoryReadPaging.ReadAllAsync(
|
||||
(cp, release, token) => ReadHistoryPageAsync(details, nodeId, cp, release, TimestampsToReturn.Both, token),
|
||||
result => result.HistoryData is ExtensionObject { Body: HistoryEvent historyEvent }
|
||||
? historyEvent.Events.Select(e => new Models.HistoryEventEntry(e.EventFields?.ToArray() ?? []))
|
||||
result => result.HistoryData.TryGetValue(out HistoryEvent? historyEvent)
|
||||
? (historyEvent.Events.ToArray() ?? []).Select(e => new Models.HistoryEventEntry(e.EventFields.ToArray() ?? []))
|
||||
: [],
|
||||
maxEvents, nodeId, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static IEnumerable<Models.HistoryModifiedEntry> ToModifiedEntries(HistoryReadResult result)
|
||||
{
|
||||
if (result.HistoryData is not ExtensionObject { Body: HistoryModifiedData modified }) yield break;
|
||||
if (!result.HistoryData.TryGetValue(out HistoryModifiedData? modified)) yield break;
|
||||
for (var i = 0; i < modified.DataValues.Count; i++)
|
||||
{
|
||||
var info = i < modified.ModificationInfos.Count ? modified.ModificationInfos[i] : null;
|
||||
yield return new Models.HistoryModifiedEntry(
|
||||
modified.DataValues[i],
|
||||
info?.ModificationTime ?? DateTime.MinValue,
|
||||
info?.ModificationTime.ToDateTime() ?? DateTime.MinValue,
|
||||
info?.UpdateType ?? default,
|
||||
info?.UserName);
|
||||
}
|
||||
@@ -218,10 +228,10 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
HistoryReadDetails details, NodeId nodeId, byte[]? continuationPoint, bool release,
|
||||
TimestampsToReturn timestamps, CancellationToken ct)
|
||||
{
|
||||
var nodesToRead = new HistoryReadValueIdCollection
|
||||
{
|
||||
new HistoryReadValueId { NodeId = nodeId, ContinuationPoint = continuationPoint }
|
||||
};
|
||||
ArrayOf<HistoryReadValueId> nodesToRead =
|
||||
[
|
||||
new HistoryReadValueId { NodeId = nodeId, ContinuationPoint = ByteString.From(continuationPoint) }
|
||||
];
|
||||
|
||||
// The async overload honours the token and does not block the caller's thread (the UI dispatcher for
|
||||
// client.ui consumers).
|
||||
@@ -244,10 +254,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
AggregateType = [aggregateId]
|
||||
};
|
||||
|
||||
var nodesToRead = new HistoryReadValueIdCollection
|
||||
{
|
||||
new HistoryReadValueId { NodeId = nodeId }
|
||||
};
|
||||
ArrayOf<HistoryReadValueId> nodesToRead = [new HistoryReadValueId { NodeId = nodeId }];
|
||||
|
||||
// Use the async overload so the method honors the cancellation token and
|
||||
// does not block on a synchronous service round-trip.
|
||||
@@ -262,13 +269,23 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
var results = response.Results;
|
||||
var allValues = new List<DataValue>();
|
||||
|
||||
if (results != null && results.Count > 0)
|
||||
if (results.Count > 0)
|
||||
{
|
||||
var result = results[0];
|
||||
HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
|
||||
if (result.HistoryData is ExtensionObject ext &&
|
||||
ext.Body is HistoryData historyData)
|
||||
allValues.AddRange(historyData.DataValues);
|
||||
try
|
||||
{
|
||||
HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
|
||||
if (result.HistoryData.TryGetValue(out HistoryData? historyData))
|
||||
allValues.AddRange(historyData.DataValues);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Keep the existing one-shot processed read; it owns no outstanding server cursor on return.
|
||||
if (!result.ContinuationPoint.IsEmpty)
|
||||
await HistoryReadPaging.ReleaseAsync(
|
||||
(cp, release, token) => ReadHistoryPageAsync(details, nodeId, cp, release, TimestampsToReturn.Source, token),
|
||||
result.ContinuationPoint.ToArray(), nodeId).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return allValues;
|
||||
@@ -322,6 +339,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
||||
try
|
||||
{
|
||||
if (_session.Connected) DisposeBridge.Run(ct => _session.CloseAsync(ct));
|
||||
@@ -330,7 +348,8 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
{
|
||||
}
|
||||
|
||||
_session.Dispose();
|
||||
try { _session.Dispose(); }
|
||||
finally { _certificateManager?.Dispose(); }
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -339,20 +358,20 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
{
|
||||
var result = await _session.CallAsync(
|
||||
null,
|
||||
new CallMethodRequestCollection
|
||||
new CallMethodRequest[]
|
||||
{
|
||||
new()
|
||||
{
|
||||
ObjectId = objectId,
|
||||
MethodId = methodId,
|
||||
InputArguments = new VariantCollection(inputArguments.Select(a => new Variant(a)))
|
||||
InputArguments = inputArguments.Select(ValueConverter.ToVariant).ToArrayOf()
|
||||
}
|
||||
},
|
||||
ct);
|
||||
|
||||
// An empty Results collection paired with a service fault must surface as
|
||||
// a ServiceResultException, not an IndexOutOfRangeException from Results[0].
|
||||
if (result.Results == null || result.Results.Count == 0)
|
||||
if (result.Results.Count == 0)
|
||||
{
|
||||
var serviceResult = result.ResponseHeader?.ServiceResult.Code ?? StatusCodes.BadUnexpectedError;
|
||||
throw new ServiceResultException(serviceResult,
|
||||
@@ -363,6 +382,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
if (StatusCode.IsBad(callResult.StatusCode))
|
||||
throw new ServiceResultException(callResult.StatusCode);
|
||||
|
||||
return callResult.OutputArguments?.Select(v => v.Value).ToList();
|
||||
return callResult.OutputArguments.IsNull ? null
|
||||
: (callResult.OutputArguments.ToArray() ?? []).Select(v => ValueConverter.ToClrObject(v)!).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,13 +30,13 @@ internal sealed class DefaultSessionFactory : ISessionFactory
|
||||
sessionName,
|
||||
sessionTimeoutMs,
|
||||
identity,
|
||||
null,
|
||||
default,
|
||||
ct);
|
||||
var session = created as Session
|
||||
?? throw new InvalidOperationException(
|
||||
$"Session factory returned {created.GetType().Name}, expected {nameof(Session)}.");
|
||||
|
||||
Logger.Information("Session created: {SessionName} -> {EndpointUrl}", sessionName, endpoint.EndpointUrl);
|
||||
return new DefaultSessionAdapter(session);
|
||||
return new DefaultSessionAdapter(session, config.CertificateManager as IDisposable);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
|
||||
private readonly Subscription _subscription;
|
||||
private readonly Func<Subscription, CancellationToken, Task>? _detach;
|
||||
private int _detached;
|
||||
private int _finishing;
|
||||
private readonly TaskCompletionSource _finished = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a live OPC UA subscription so client code can manage monitored items through a testable abstraction.
|
||||
@@ -47,7 +49,7 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
|
||||
|
||||
item.Notification += (_, e) =>
|
||||
{
|
||||
if (e.NotificationValue is MonitoredItemNotification notification)
|
||||
if (Volatile.Read(ref _finishing) == 0 && e.NotificationValue is MonitoredItemNotification notification)
|
||||
onDataChange(nodeId.ToString(), notification.Value);
|
||||
};
|
||||
|
||||
@@ -90,7 +92,7 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
|
||||
|
||||
item.Notification += (_, e) =>
|
||||
{
|
||||
if (e.NotificationValue is EventFieldList eventFields) onEvent(eventFields);
|
||||
if (Volatile.Read(ref _finishing) == 0 && e.NotificationValue is EventFieldList eventFields) onEvent(eventFields);
|
||||
};
|
||||
|
||||
_subscription.AddItem(item);
|
||||
@@ -112,19 +114,28 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteAsync(CancellationToken ct)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _finishing, 1) != 0)
|
||||
{
|
||||
await _finished.Task.WaitAsync(ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
// Forward the caller's token so the delete can be cancelled.
|
||||
await _subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning(ex, "Error deleting subscription");
|
||||
}
|
||||
try { await _subscription.DeleteAsync(true, ct).ConfigureAwait(false); }
|
||||
catch (Exception ex) { Logger.Warning(ex, "Error deleting subscription"); }
|
||||
|
||||
// Detach whether or not the server-side delete succeeded: the adapter is finished with it either way.
|
||||
await DetachAsync(ct);
|
||||
_monitoredItems.Clear();
|
||||
// A cancelled delete still has local resources to release. Cleanup gets its own bounded token.
|
||||
using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
try { await DetachAsync(cleanup.Token).WaitAsync(cleanup.Token).ConfigureAwait(false); }
|
||||
catch (Exception ex) { Logger.Warning(ex, "Error detaching subscription"); }
|
||||
finally
|
||||
{
|
||||
_monitoredItems.Clear();
|
||||
try { await Task.Run(_subscription.Dispose).WaitAsync(cleanup.Token).ConfigureAwait(false); }
|
||||
catch (Exception ex) { Logger.Warning(ex, "Error disposing subscription resources"); }
|
||||
}
|
||||
}
|
||||
finally { _finished.TrySetResult(); }
|
||||
}
|
||||
|
||||
private async Task DetachAsync(CancellationToken ct)
|
||||
@@ -145,21 +156,6 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
// One bounded bridge (5 s) for delete + detach, so a dispose never blocks longer than before (review m6).
|
||||
// DetachAsync swallows its own failures; a failed delete must not skip the detach.
|
||||
try
|
||||
{
|
||||
DisposeBridge.Run(async ct =>
|
||||
{
|
||||
try { await _subscription.DeleteAsync(true, ct); }
|
||||
catch (Exception ex) { Logger.Warning(ex, "Error deleting subscription"); }
|
||||
await DetachAsync(ct);
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
_monitoredItems.Clear();
|
||||
DisposeBridge.Run(DeleteAsync);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ internal interface ISessionAdapter : IDisposable
|
||||
/// <param name="nodeClassMask">The node classes that should be returned to the caller.</param>
|
||||
/// <param name="ct">The cancellation token that aborts the browse request.</param>
|
||||
/// <returns>A continuation point (or <c>null</c> when complete) and the references found.</returns>
|
||||
Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync(
|
||||
Task<(byte[]? ContinuationPoint, ArrayOf<ReferenceDescription> References)> BrowseAsync(
|
||||
NodeId nodeId, uint nodeClassMask = 0, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
@@ -87,9 +87,15 @@ internal interface ISessionAdapter : IDisposable
|
||||
/// <param name="continuationPoint">The continuation token returned by a prior browse result page.</param>
|
||||
/// <param name="ct">The cancellation token that aborts the browse-next request.</param>
|
||||
/// <returns>The next continuation point (or <c>null</c> when exhausted) and the additional references.</returns>
|
||||
Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
|
||||
Task<(byte[]? ContinuationPoint, ArrayOf<ReferenceDescription> References)> BrowseNextAsync(
|
||||
byte[] continuationPoint, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Releases a browse cursor that the caller stops following.</summary>
|
||||
/// <param name="continuationPoint">The cursor owned by this session.</param>
|
||||
/// <param name="ct">A bounded cleanup token independent of the cancelled browse.</param>
|
||||
/// <returns>The completion of the release request.</returns>
|
||||
Task ReleaseBrowseContinuationPointAsync(byte[] continuationPoint, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Reads raw historical data.
|
||||
/// </summary>
|
||||
|
||||
@@ -56,15 +56,15 @@ internal static class HistoryReadPaging
|
||||
HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
|
||||
all.AddRange(extract(result));
|
||||
|
||||
continuationPoint = result.ContinuationPoint;
|
||||
continuationPoint = result.ContinuationPoint.IsNull ? null : result.ContinuationPoint.ToArray();
|
||||
if (continuationPoint is not { Length: > 0 }) return Trim(all, maxValues);
|
||||
if (all.Count >= maxValues) break;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
catch
|
||||
{
|
||||
// Cancelled between pages (or during page N+1) with a cursor outstanding: release it rather than leave it
|
||||
// occupying one of the session's continuation-point slots, then surface the cancellation.
|
||||
// A refused/failed/cancelled continuation still leaves the last cursor owned by this read.
|
||||
// Best-effort release must preserve the original operation failure.
|
||||
if (continuationPoint is { Length: > 0 }) await ReleaseAsync(readPage, continuationPoint, nodeId);
|
||||
throw;
|
||||
}
|
||||
@@ -81,12 +81,12 @@ internal static class HistoryReadPaging
|
||||
return all;
|
||||
}
|
||||
|
||||
private static async Task ReleaseAsync(PageReader readPage, byte[] continuationPoint, NodeId nodeId)
|
||||
internal static async Task ReleaseAsync(PageReader readPage, byte[] continuationPoint, NodeId nodeId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(ReleaseTimeout);
|
||||
await readPage(continuationPoint, true, cts.Token).ConfigureAwait(false);
|
||||
await readPage(continuationPoint, true, cts.Token).WaitAsync(cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -19,6 +19,6 @@ internal static class HistoryReadStatus
|
||||
{
|
||||
if (!StatusCode.IsBad(status) || status.Code == StatusCodes.BadNoData) return;
|
||||
throw new ServiceResultException(status.Code,
|
||||
$"HistoryRead of {nodeId} failed: {StatusCodes.GetBrowseName(status.Code)} (0x{status.Code:X8}).");
|
||||
$"HistoryRead of {nodeId} failed: {status.ToString()} (0x{status.Code:X8}).");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Opc.Ua;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||
|
||||
/// <summary>
|
||||
@@ -6,6 +8,49 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||
/// </summary>
|
||||
public static class ValueConverter
|
||||
{
|
||||
/// <summary>Converts a supported client payload to an SDK value, detaching caller-owned buffers.</summary>
|
||||
/// <param name="value">The existing CLR or SDK payload.</param>
|
||||
/// <returns>The corresponding OPC UA value.</returns>
|
||||
/// <exception cref="ServiceResultException">The payload shape is unsupported.</exception>
|
||||
public static Variant ToVariant(object? value)
|
||||
{
|
||||
// Historically a CLR byte[] supplied to a client write/method was a scalar ByteString.
|
||||
if (value is byte[] bytes && bytes.GetType() == typeof(byte[])) return Variant.From(ByteString.From(bytes.AsSpan()));
|
||||
if (value is ByteString byteString) return Variant.From(byteString.IsNull ? default : byteString.Copy());
|
||||
if (!VariantHelper.TryCastFrom(value, out Variant variant))
|
||||
throw new ServiceResultException(StatusCodes.BadTypeMismatch, "Unsupported client value type or shape.");
|
||||
if (variant.TypeInfo.IsMatrix)
|
||||
throw new ServiceResultException(StatusCodes.BadTypeMismatch, "Unsupported client matrix shape.");
|
||||
if (variant.TypeInfo.IsArray && variant.TryGetValue(out ArrayOf<ByteString> buffers))
|
||||
return Variant.From(buffers.IsNull ? default : buffers.ConvertAll(b => b.IsNull ? default : b.Copy()));
|
||||
// ArrayOf is read-only, but can wrap externally owned ReadOnlyMemory. Detach it too.
|
||||
if (value is not Array && variant.TypeInfo.IsArray && variant.AsBoxedObject(Variant.BoxingBehavior.Legacy) is Array array)
|
||||
{
|
||||
if (!VariantHelper.TryCastFrom(array, out variant))
|
||||
throw new ServiceResultException(StatusCodes.BadTypeMismatch, "Unsupported client array type.");
|
||||
}
|
||||
else if (variant.TypeInfo.IsScalar && variant.TryGetValue(out ByteString retainedBytes))
|
||||
variant = Variant.From(retainedBytes.IsNull ? default : retainedBytes.Copy());
|
||||
return variant;
|
||||
}
|
||||
|
||||
/// <summary>Returns the CLR representation expected by existing client object consumers.</summary>
|
||||
/// <param name="value">The SDK value to inspect.</param>
|
||||
/// <returns>A CLR scalar or array, preserving null versus empty values.</returns>
|
||||
public static object? ToClrObject(Variant value)
|
||||
{
|
||||
return value.AsBoxedObject(Variant.BoxingBehavior.Legacy) switch
|
||||
{
|
||||
ByteString bytes => bytes.IsNull ? null : bytes.ToArray(),
|
||||
DateTimeUtc timestamp => timestamp.ToDateTime(),
|
||||
Uuid guid => (Guid)guid,
|
||||
DateTimeUtc[] timestamps => timestamps.Select(t => t.ToDateTime()).ToArray(),
|
||||
Uuid[] guids => guids.Select(g => (Guid)g).ToArray(),
|
||||
ByteString[] buffers => buffers.Select(b => b.IsNull ? null : b.ToArray()).ToArray(),
|
||||
var payload => payload
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a raw string value into the runtime type expected by the target node.
|
||||
/// </summary>
|
||||
|
||||
@@ -221,13 +221,13 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
if (value is string rawString)
|
||||
{
|
||||
var currentDataValue = await _session!.ReadValueAsync(nodeId, ct);
|
||||
if (!StatusCode.IsGood(currentDataValue.StatusCode) || currentDataValue.Value == null)
|
||||
if (!StatusCode.IsGood(currentDataValue.StatusCode) || ValueConverter.ToClrObject(currentDataValue.WrappedValue) == null)
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot infer target type for node {nodeId}: read returned status {currentDataValue.StatusCode} with no value. Provide a typed value instead of a string.");
|
||||
typedValue = ValueConverter.ConvertValue(rawString, currentDataValue.Value);
|
||||
typedValue = ValueConverter.ConvertValue(rawString, ValueConverter.ToClrObject(currentDataValue.WrappedValue));
|
||||
}
|
||||
|
||||
var dataValue = new DataValue(new Variant(typedValue));
|
||||
var dataValue = new DataValue(ValueConverter.ToVariant(typedValue));
|
||||
return await _session!.WriteValueAsync(nodeId, dataValue, ct);
|
||||
}
|
||||
|
||||
@@ -242,22 +242,39 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
var nodeClassMask = (uint)NodeClass.Object | (uint)NodeClass.Variable | (uint)NodeClass.Method;
|
||||
var results = new List<BrowseResult>();
|
||||
|
||||
var (continuationPoint, references) = await _session!.BrowseAsync(startNode, nodeClassMask, ct);
|
||||
|
||||
while (references.Count > 0)
|
||||
// A cursor belongs to the session that issued it, including while failover swaps _session.
|
||||
var session = _session!;
|
||||
byte[]? continuationPoint = null;
|
||||
try
|
||||
{
|
||||
// Child detection is lazy: an Object MAY have children and is confirmed when the caller expands or
|
||||
// recurses into it. Probing each Object child with its own Browse made one level of N folders cost N + 1
|
||||
// round trips, and a recursive subscribe over a 50k-node tree ~50k extra Browse calls.
|
||||
foreach (var reference in references)
|
||||
results.Add(new BrowseResult(
|
||||
reference.NodeId.ToString(),
|
||||
reference.DisplayName?.Text ?? string.Empty,
|
||||
reference.NodeClass.ToString(),
|
||||
reference.NodeClass == NodeClass.Object));
|
||||
ArrayOf<ReferenceDescription> references;
|
||||
(continuationPoint, references) = await session.BrowseAsync(startNode, nodeClassMask, ct);
|
||||
while (references.Count > 0)
|
||||
{
|
||||
foreach (var reference in references)
|
||||
results.Add(new BrowseResult(
|
||||
reference.NodeId.ToString(),
|
||||
reference.DisplayName.Text ?? string.Empty,
|
||||
reference.NodeClass.ToString(),
|
||||
reference.NodeClass == NodeClass.Object));
|
||||
|
||||
if (continuationPoint is not { Length: > 0 } pending) break;
|
||||
(continuationPoint, references) = await _session.BrowseNextAsync(pending, ct);
|
||||
if (continuationPoint is not { Length: > 0 } pending) break;
|
||||
ct.ThrowIfCancellationRequested();
|
||||
(continuationPoint, references) = await session.BrowseNextAsync(pending, ct);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (continuationPoint is { Length: > 0 })
|
||||
{
|
||||
try
|
||||
{
|
||||
using var release = new CancellationTokenSource(HistoryReadPaging.ReleaseTimeout);
|
||||
await session.ReleaseBrowseContinuationPointAsync(continuationPoint, release.Token)
|
||||
.WaitAsync(release.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) { Logger.Warning(ex, "Releasing browse continuation point failed"); }
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
@@ -293,7 +310,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
}
|
||||
|
||||
var handle = await subscription.AddDataChangeMonitoredItemAsync(
|
||||
nodeId, intervalMs, OnDataChangeNotification, ct);
|
||||
nodeId, intervalMs, DataCallback(session, subscription), ct);
|
||||
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
@@ -377,7 +394,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
|
||||
var filter = CreateAlarmEventFilter();
|
||||
await subscription.AddEventMonitoredItemAsync(
|
||||
monitorNode, intervalMs, filter, OnAlarmEventNotification, ct);
|
||||
monitorNode, intervalMs, filter, AlarmCallback(session, subscription), ct);
|
||||
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
@@ -683,15 +700,15 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
var redundancySupportValue =
|
||||
await _session!.ReadValueAsync(VariableIds.Server_ServerRedundancy_RedundancySupport, ct);
|
||||
RedundancySupport redundancySupport;
|
||||
if (StatusCode.IsGood(redundancySupportValue.StatusCode) && redundancySupportValue.Value != null)
|
||||
redundancySupport = (RedundancySupport)Convert.ToInt32(redundancySupportValue.Value);
|
||||
if (StatusCode.IsGood(redundancySupportValue.StatusCode) && ValueConverter.ToClrObject(redundancySupportValue.WrappedValue) != null)
|
||||
redundancySupport = (RedundancySupport)Convert.ToInt32(ValueConverter.ToClrObject(redundancySupportValue.WrappedValue));
|
||||
else
|
||||
redundancySupport = RedundancySupport.None;
|
||||
var redundancyMode = redundancySupport.ToString();
|
||||
|
||||
var serviceLevelValue = await _session.ReadValueAsync(VariableIds.Server_ServiceLevel, ct);
|
||||
var serviceLevel = StatusCode.IsGood(serviceLevelValue.StatusCode) && serviceLevelValue.Value != null
|
||||
? Convert.ToByte(serviceLevelValue.Value)
|
||||
var serviceLevel = StatusCode.IsGood(serviceLevelValue.StatusCode) && ValueConverter.ToClrObject(serviceLevelValue.WrappedValue) != null
|
||||
? Convert.ToByte(ValueConverter.ToClrObject(serviceLevelValue.WrappedValue))
|
||||
: (byte)0;
|
||||
|
||||
string[] serverUris = [];
|
||||
@@ -699,7 +716,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
{
|
||||
var serverUriArrayValue =
|
||||
await _session.ReadValueAsync(VariableIds.Server_ServerRedundancy_ServerUriArray, ct);
|
||||
if (serverUriArrayValue.Value is string[] uris)
|
||||
if (ValueConverter.ToClrObject(serverUriArrayValue.WrappedValue) is string[] uris)
|
||||
serverUris = uris;
|
||||
}
|
||||
catch
|
||||
@@ -711,7 +728,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
try
|
||||
{
|
||||
var serverArrayValue = await _session.ReadValueAsync(VariableIds.Server_ServerArray, ct);
|
||||
if (serverArrayValue.Value is string[] serverArray && serverArray.Length > 0)
|
||||
if (ValueConverter.ToClrObject(serverArrayValue.WrappedValue) is string[] serverArray && serverArray.Length > 0)
|
||||
applicationUri = serverArray[0];
|
||||
}
|
||||
catch
|
||||
@@ -766,16 +783,26 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
};
|
||||
|
||||
var config = await _configFactory.CreateAsync(effectiveSettings, ct);
|
||||
var requestedMode = SecurityModeMapper.ToMessageSecurityMode(settings.SecurityMode);
|
||||
var endpoint = await _endpointDiscovery.SelectEndpointAsync(config, endpointUrl, requestedMode, ct);
|
||||
try
|
||||
{
|
||||
var requestedMode = SecurityModeMapper.ToMessageSecurityMode(settings.SecurityMode);
|
||||
var endpoint = await _endpointDiscovery.SelectEndpointAsync(config, endpointUrl, requestedMode, ct);
|
||||
|
||||
var identity = settings.Username != null
|
||||
? new UserIdentity(settings.Username, Encoding.UTF8.GetBytes(settings.Password ?? ""))
|
||||
: new UserIdentity();
|
||||
var identity = settings.Username != null
|
||||
? new UserIdentity(settings.Username, Encoding.UTF8.GetBytes(settings.Password ?? ""))
|
||||
: new UserIdentity();
|
||||
|
||||
var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000);
|
||||
return await _sessionFactory.CreateSessionAsync(config, endpoint, "OtOpcUaClient", sessionTimeoutMs, identity,
|
||||
ct);
|
||||
var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000);
|
||||
return await _sessionFactory.CreateSessionAsync(config, endpoint, "OtOpcUaClient", sessionTimeoutMs, identity,
|
||||
ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The session adapter owns it after successful creation; a failed attempt has no adapter owner.
|
||||
try { (config.CertificateManager as IDisposable)?.Dispose(); }
|
||||
catch (Exception ex) { Logger.Warning(ex, "Disposing certificate manager after connection failure"); }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A bad keep-alive from the session of connection <paramref name="epoch"/>.</summary>
|
||||
@@ -962,7 +989,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
}
|
||||
|
||||
var handle = await dataSubscription.AddDataChangeMonitoredItemAsync(
|
||||
nodeId, intervalMs, OnDataChangeNotification, CancellationToken.None);
|
||||
nodeId, intervalMs, DataCallback(session, dataSubscription), CancellationToken.None);
|
||||
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
@@ -985,7 +1012,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
lock (_subscriptionLock) _alarmSubscription = replayedAlarms;
|
||||
var filter = CreateAlarmEventFilter();
|
||||
await replayedAlarms.AddEventMonitoredItemAsync(
|
||||
monitorNode, intervalMs, filter, OnAlarmEventNotification, CancellationToken.None);
|
||||
monitorNode, intervalMs, filter, AlarmCallback(session, replayedAlarms), CancellationToken.None);
|
||||
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
@@ -999,6 +1026,25 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsCurrentNotification(ISessionAdapter session, ISubscriptionAdapter subscription, bool alarm)
|
||||
{
|
||||
lock (_subscriptionLock)
|
||||
return !_disposed && _state == ConnectionState.Connected && ReferenceEquals(_session, session)
|
||||
&& ReferenceEquals(alarm ? _alarmSubscription : _dataSubscription, subscription);
|
||||
}
|
||||
|
||||
private Action<string, DataValue> DataCallback(ISessionAdapter session, ISubscriptionAdapter subscription) =>
|
||||
(nodeId, value) =>
|
||||
{
|
||||
if (IsCurrentNotification(session, subscription, false)) OnDataChangeNotification(nodeId, value);
|
||||
};
|
||||
|
||||
private Action<EventFieldList> AlarmCallback(ISessionAdapter session, ISubscriptionAdapter subscription) =>
|
||||
fields =>
|
||||
{
|
||||
if (IsCurrentNotification(session, subscription, true)) OnAlarmEventNotification(fields);
|
||||
};
|
||||
|
||||
private void OnDataChangeNotification(string nodeId, DataValue value)
|
||||
{
|
||||
DataChanged?.Invoke(this, new DataChangedEventArgs(nodeId, value));
|
||||
@@ -1007,28 +1053,28 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
private void OnAlarmEventNotification(EventFieldList eventFields)
|
||||
{
|
||||
var fields = eventFields.EventFields;
|
||||
if (fields == null || fields.Count < 1)
|
||||
if (fields.Count < 1)
|
||||
return;
|
||||
|
||||
var eventId = fields.Count > 0 ? fields[0].Value as byte[] : null;
|
||||
var sourceName = fields.Count > 2 ? fields[2].Value as string ?? string.Empty : string.Empty;
|
||||
var eventId = fields.Count > 0 ? ValueConverter.ToClrObject(fields[0]) as byte[] : null;
|
||||
var sourceName = fields.Count > 2 ? ValueConverter.ToClrObject(fields[2]) as string ?? string.Empty : string.Empty;
|
||||
var time = DateTime.MinValue;
|
||||
if (fields.Count > 3 && fields[3].Value != null)
|
||||
if (fields.Count > 3 && ValueConverter.ToClrObject(fields[3]) != null)
|
||||
{
|
||||
if (fields[3].Value is DateTime dt)
|
||||
if (ValueConverter.ToClrObject(fields[3]) is DateTime dt)
|
||||
time = dt;
|
||||
else if (DateTime.TryParse(fields[3].Value.ToString(), out var parsed))
|
||||
else if (DateTime.TryParse(ValueConverter.ToClrObject(fields[3])?.ToString(), out var parsed))
|
||||
time = parsed;
|
||||
}
|
||||
var message = fields.Count > 4 ? (fields[4].Value as LocalizedText)?.Text ?? string.Empty : string.Empty;
|
||||
var severity = fields.Count > 5 ? Convert.ToUInt16(fields[5].Value) : (ushort)0;
|
||||
var conditionName = fields.Count > 6 ? fields[6].Value as string ?? string.Empty : string.Empty;
|
||||
var retain = fields.Count > 7 && ParseBool(fields[7].Value);
|
||||
var conditionNodeId = fields.Count > 12 ? fields[12].Value?.ToString() : null;
|
||||
var message = fields.Count > 4 ? (fields[4].TryGetValue(out LocalizedText text) ? text.Text : null) ?? string.Empty : string.Empty;
|
||||
var severity = fields.Count > 5 ? Convert.ToUInt16(ValueConverter.ToClrObject(fields[5])) : (ushort)0;
|
||||
var conditionName = fields.Count > 6 ? ValueConverter.ToClrObject(fields[6]) as string ?? string.Empty : string.Empty;
|
||||
var retain = fields.Count > 7 && ParseBool(ValueConverter.ToClrObject(fields[7]));
|
||||
var conditionNodeId = fields.Count > 12 ? ValueConverter.ToClrObject(fields[12])?.ToString() : null;
|
||||
|
||||
// Try standard OPC UA ActiveState/AckedState fields first
|
||||
bool? ackedField = fields.Count > 8 && fields[8].Value != null ? ParseBool(fields[8].Value) : null;
|
||||
bool? activeField = fields.Count > 9 && fields[9].Value != null ? ParseBool(fields[9].Value) : null;
|
||||
bool? ackedField = fields.Count > 8 && ValueConverter.ToClrObject(fields[8]) != null ? ParseBool(ValueConverter.ToClrObject(fields[8])) : null;
|
||||
bool? activeField = fields.Count > 9 && ValueConverter.ToClrObject(fields[9]) != null ? ParseBool(ValueConverter.ToClrObject(fields[9])) : null;
|
||||
|
||||
var ackedState = ackedField ?? false;
|
||||
var activeState = activeField ?? false;
|
||||
@@ -1053,39 +1099,25 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
/// <returns>The populated <see cref="EventFilter"/>.</returns>
|
||||
internal static EventFilter CreateAlarmEventFilter()
|
||||
{
|
||||
var filter = new EventFilter();
|
||||
// 0: EventId
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventId);
|
||||
// 1: EventType
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventType);
|
||||
// 2: SourceName
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceName);
|
||||
// 3: Time
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Time);
|
||||
// 4: Message
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Message);
|
||||
// 5: Severity
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Severity);
|
||||
// 6: ConditionName
|
||||
filter.AddSelectClause(ObjectTypeIds.ConditionType, BrowseNames.ConditionName);
|
||||
// 7: Retain
|
||||
filter.AddSelectClause(ObjectTypeIds.ConditionType, BrowseNames.Retain);
|
||||
// 8-10: the two-state /Id booleans. Each browse path is TWO QualifiedNames. Passing "AckedState/Id" as a
|
||||
// string to AddSelectClause does not split it — it selects one element NAMED "AckedState/Id", which no
|
||||
// event carries, so all three arrived null and every alarm read inactive + unacknowledged.
|
||||
// 8: AckedState/Id
|
||||
filter.SelectClauses.Add(new SimpleAttributeOperand(
|
||||
ObjectTypeIds.AcknowledgeableConditionType, [BrowseNames.AckedState, BrowseNames.Id]));
|
||||
// 9: ActiveState/Id
|
||||
filter.SelectClauses.Add(new SimpleAttributeOperand(
|
||||
ObjectTypeIds.AlarmConditionType, [BrowseNames.ActiveState, BrowseNames.Id]));
|
||||
// 10: EnabledState/Id
|
||||
filter.SelectClauses.Add(new SimpleAttributeOperand(
|
||||
ObjectTypeIds.ConditionType, [BrowseNames.EnabledState, BrowseNames.Id]));
|
||||
// 11: SuppressedOrShelved
|
||||
filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "SuppressedOrShelved");
|
||||
// 12: SourceNode (ConditionId for acknowledgment)
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceNode);
|
||||
var filter = new EventFilter
|
||||
{
|
||||
SelectClauses =
|
||||
[
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("EventId")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("EventType")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("SourceName")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("Time")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("Message")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("Severity")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.ConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("ConditionName")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.ConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("Retain")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.AcknowledgeableConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("AckedState"), new QualifiedName("Id")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.AlarmConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("ActiveState"), new QualifiedName("Id")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.ConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("EnabledState"), new QualifiedName("Id")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.AlarmConditionType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("SuppressedOrShelved")] },
|
||||
new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, AttributeId = Attributes.Value, BrowsePath = [new QualifiedName("SourceNode")] },
|
||||
]
|
||||
};
|
||||
return filter;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer" PrivateAssets="all" IncludeAssets="analyzers;build;buildtransitive" />
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client"/>
|
||||
<PackageReference Include="Serilog"/>
|
||||
</ItemGroup>
|
||||
|
||||
@@ -16,7 +16,7 @@ internal static class StatusCodeFormatter
|
||||
var hex = $"0x{code:X8}";
|
||||
|
||||
// Try the SDK's browse name lookup first
|
||||
var name = StatusCodes.GetBrowseName(code);
|
||||
var name = statusCode.SymbolicId;
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
return $"{hex} ({name})";
|
||||
|
||||
@@ -24,7 +24,7 @@ internal static class StatusCodeFormatter
|
||||
var mainCode = code & 0xFFFF0000;
|
||||
if (mainCode != code)
|
||||
{
|
||||
name = StatusCodes.GetBrowseName(mainCode);
|
||||
name = new StatusCode(mainCode).SymbolicId;
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
return $"{hex} ({name})";
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ public partial class AlarmsViewModel : ObservableObject
|
||||
{
|
||||
try
|
||||
{
|
||||
var sourceNodeId = string.IsNullOrWhiteSpace(MonitoredNodeIdText)
|
||||
NodeId? sourceNodeId = string.IsNullOrWhiteSpace(MonitoredNodeIdText)
|
||||
? null
|
||||
: NodeId.Parse(MonitoredNodeIdText);
|
||||
|
||||
@@ -275,7 +275,7 @@ public partial class AlarmsViewModel : ObservableObject
|
||||
|
||||
try
|
||||
{
|
||||
var nodeId = string.IsNullOrWhiteSpace(sourceNodeId)
|
||||
NodeId? nodeId = string.IsNullOrWhiteSpace(sourceNodeId)
|
||||
? null
|
||||
: NodeId.Parse(sourceNodeId);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
@@ -97,7 +98,7 @@ public partial class HistoryViewModel : ObservableObject
|
||||
{
|
||||
foreach (var dv in values)
|
||||
Results.Add(new HistoryValueViewModel(
|
||||
Helpers.ValueFormatter.Format(dv.Value),
|
||||
Helpers.ValueFormatter.Format(ValueConverter.ToClrObject(dv.WrappedValue)),
|
||||
Helpers.StatusCodeFormatter.Format(dv.StatusCode),
|
||||
dv.SourceTimestamp.ToString("O"),
|
||||
dv.ServerTimestamp.ToString("O")));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Opc.Ua;
|
||||
@@ -76,7 +77,7 @@ public partial class ReadWriteViewModel : ObservableObject
|
||||
|
||||
_dispatcher.Post(() =>
|
||||
{
|
||||
CurrentValue = Helpers.ValueFormatter.Format(dataValue.Value);
|
||||
CurrentValue = Helpers.ValueFormatter.Format(ValueConverter.ToClrObject(dataValue.WrappedValue));
|
||||
CurrentStatus = Helpers.StatusCodeFormatter.Format(dataValue.StatusCode);
|
||||
SourceTimestamp = dataValue.SourceTimestamp.ToString("O");
|
||||
ServerTimestamp = dataValue.ServerTimestamp.ToString("O");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
@@ -75,7 +76,7 @@ public partial class SubscriptionsViewModel : ObservableObject
|
||||
if (!_rowsByNodeId.TryGetValue(e.NodeId, out var rows)) return;
|
||||
foreach (var item in rows)
|
||||
{
|
||||
item.Value = Helpers.ValueFormatter.Format(e.Value.Value);
|
||||
item.Value = Helpers.ValueFormatter.Format(ValueConverter.ToClrObject(e.Value.WrappedValue));
|
||||
item.Status = Helpers.StatusCodeFormatter.Format(e.Value.StatusCode);
|
||||
item.Timestamp = e.Value.SourceTimestamp.ToString("O");
|
||||
}
|
||||
@@ -281,7 +282,7 @@ public partial class SubscriptionsViewModel : ObservableObject
|
||||
|
||||
// Read current value to determine target type
|
||||
var currentDataValue = await _service.ReadValueAsync(nodeId);
|
||||
var currentValue = currentDataValue.Value;
|
||||
var currentValue = ValueConverter.ToClrObject(currentDataValue.WrappedValue);
|
||||
|
||||
// Try parsing to the target type before writing
|
||||
try
|
||||
|
||||
@@ -27,10 +27,11 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
/// </remarks>
|
||||
internal static class AdvertisedAggregateFunctions
|
||||
{
|
||||
private static readonly object SyncGate = new();
|
||||
/// <summary>Every standard aggregate-function NodeId the SDK's nodeset knows (<c>ObjectIds.AggregateFunction_*</c>).</summary>
|
||||
private static readonly NodeId[] StandardAggregateIds = typeof(ObjectIds)
|
||||
.GetFields(BindingFlags.Public | BindingFlags.Static)
|
||||
.Where(f => f.FieldType == typeof(NodeId) && f.Name.StartsWith("AggregateFunction_", StringComparison.Ordinal))
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Static)
|
||||
.Where(f => f.PropertyType == typeof(NodeId) && f.Name.StartsWith("AggregateFunction_", StringComparison.Ordinal))
|
||||
.Select(f => (NodeId)f.GetValue(null)!)
|
||||
.ToArray();
|
||||
|
||||
@@ -56,10 +57,10 @@ internal static class AdvertisedAggregateFunctions
|
||||
/// <returns>The advertised NodeIds, or <c>null</c> when the diagnostics node manager or folder is not available.</returns>
|
||||
public static IReadOnlyList<NodeId>? Apply(IServerInternal server, IReadOnlySet<HistoryAggregateType> served)
|
||||
{
|
||||
if (server?.DiagnosticsNodeManager is not CustomNodeManager2 diagnostics) return null;
|
||||
if (server?.DiagnosticsNodeManager is not DiagnosticsNodeManager diagnostics) return null;
|
||||
|
||||
var advertised = new List<NodeId>();
|
||||
lock (diagnostics.Lock)
|
||||
lock (SyncGate)
|
||||
{
|
||||
var folder = diagnostics.FindPredefinedNode<BaseObjectState>(Folder);
|
||||
if (folder is null) return null;
|
||||
|
||||
@@ -51,5 +51,5 @@ internal static class AggregateFunctionMap
|
||||
/// <param name="aggregateNodeId">The per-node aggregate-function NodeId from the request.</param>
|
||||
/// <returns>The mapped aggregate, or <c>null</c>.</returns>
|
||||
public static HistoryAggregateType? Map(NodeId? aggregateNodeId) =>
|
||||
aggregateNodeId is not null && ByNodeId.TryGetValue(aggregateNodeId, out var aggregate) ? aggregate : null;
|
||||
aggregateNodeId is { } nodeId && ByNodeId.TryGetValue(nodeId, out var aggregate) ? aggregate : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using Opc.Ua;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
/// <summary>Preserves the application's repeat-method contract at the SDK's supported validation seam.</summary>
|
||||
internal static class ApplicationAlarmConditions
|
||||
{
|
||||
internal static AlarmConditionState Create(string type, NodeState parent) => type switch
|
||||
{
|
||||
"OffNormalAlarm" => new OffNormal(parent),
|
||||
"DiscreteAlarm" => new Discrete(parent),
|
||||
_ => new Alarm(parent),
|
||||
};
|
||||
|
||||
private static ServiceResult CurrentTrunkRepeat(ServiceResult result, AcknowledgeableConditionState condition,
|
||||
ISystemContext context, ByteString eventId, LocalizedText comment, bool acknowledge)
|
||||
{
|
||||
var repeated = acknowledge ? StatusCodes.BadConditionBranchAlreadyAcked : StatusCodes.BadConditionBranchAlreadyConfirmed;
|
||||
var callback = acknowledge ? condition.OnAcknowledge : condition.OnConfirm;
|
||||
if (result.StatusCode != repeated || callback is null || eventId.IsEmpty
|
||||
|| condition.BranchId is not { Value.IsNull: true }
|
||||
|| condition.EventId is not { } current || current.Value != eventId
|
||||
|| !ReferenceEquals(condition.GetEventByEventId(eventId), condition)
|
||||
|| (acknowledge ? condition.AckedState?.Id?.Value : condition.ConfirmedState?.Id?.Value) != true)
|
||||
return result;
|
||||
|
||||
// In exact SDK 2.0.1, the Already* guards precede the application callback. Invoke it once
|
||||
// here; every other SDK validation result, superseded EventId and branch retains the base path.
|
||||
try { return callback(context, condition, eventId, comment); }
|
||||
catch (Exception error)
|
||||
{
|
||||
return ServiceResult.Create(error, StatusCodes.BadUnexpectedError,
|
||||
acknowledge ? "Unexpected error acknowledging a Condition." : "Unexpected error confirming a Condition.");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class OffNormal(NodeState parent) : OffNormalAlarmState(parent)
|
||||
{
|
||||
protected override ServiceResult ProcessBeforeAcknowledge(ISystemContext context, ByteString id, LocalizedText comment) =>
|
||||
CurrentTrunkRepeat(base.ProcessBeforeAcknowledge(context, id, comment), this, context, id, comment, true);
|
||||
protected override ServiceResult ProcessBeforeConfirm(ISystemContext context, ByteString id, LocalizedText comment) =>
|
||||
CurrentTrunkRepeat(base.ProcessBeforeConfirm(context, id, comment), this, context, id, comment, false);
|
||||
protected override ConditionState CreateBranchInstance() => new OffNormal(this);
|
||||
}
|
||||
|
||||
private sealed class Discrete(NodeState parent) : DiscreteAlarmState(parent)
|
||||
{
|
||||
protected override ServiceResult ProcessBeforeAcknowledge(ISystemContext context, ByteString id, LocalizedText comment) =>
|
||||
CurrentTrunkRepeat(base.ProcessBeforeAcknowledge(context, id, comment), this, context, id, comment, true);
|
||||
protected override ServiceResult ProcessBeforeConfirm(ISystemContext context, ByteString id, LocalizedText comment) =>
|
||||
CurrentTrunkRepeat(base.ProcessBeforeConfirm(context, id, comment), this, context, id, comment, false);
|
||||
protected override ConditionState CreateBranchInstance() => new Discrete(this);
|
||||
}
|
||||
|
||||
private sealed class Alarm(NodeState parent) : AlarmConditionState(parent)
|
||||
{
|
||||
protected override ServiceResult ProcessBeforeAcknowledge(ISystemContext context, ByteString id, LocalizedText comment) =>
|
||||
CurrentTrunkRepeat(base.ProcessBeforeAcknowledge(context, id, comment), this, context, id, comment, true);
|
||||
protected override ServiceResult ProcessBeforeConfirm(ISystemContext context, ByteString id, LocalizedText comment) =>
|
||||
CurrentTrunkRepeat(base.ProcessBeforeConfirm(context, id, comment), this, context, id, comment, false);
|
||||
protected override ConditionState CreateBranchInstance() => new Alarm(this);
|
||||
}
|
||||
}
|
||||
+1579
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,382 @@
|
||||
/* ========================================================================
|
||||
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
|
||||
*
|
||||
* OPC Foundation MIT License 1.00
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person
|
||||
* obtaining a copy of this software and associated documentation
|
||||
* files (the "Software"), to deal in the Software without
|
||||
* restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the
|
||||
* Software is furnished to do so, subject to the following
|
||||
* conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be
|
||||
* included in all copies or substantial portions of the Software.
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
* OTHER DEALINGS IN THE SOFTWARE.
|
||||
*
|
||||
* The complete license agreement can be found here:
|
||||
* http://opcfoundation.org/License/MIT/1.00/
|
||||
* ======================================================================*/
|
||||
|
||||
using Opc.Ua;
|
||||
using DataValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.LegacyDataValue;
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new instance of an aggregate factory.
|
||||
/// </summary>
|
||||
internal delegate AggregateCalculator AggregatorFactory(
|
||||
NodeId aggregateId,
|
||||
DateTime startTime,
|
||||
DateTime endTime,
|
||||
double processingInterval,
|
||||
bool stepped,
|
||||
AggregateConfiguration configuration,
|
||||
ITelemetryContext? telemetry);
|
||||
|
||||
/// <summary>
|
||||
/// The set of built-in aggregate factories.
|
||||
/// </summary>
|
||||
internal static class Aggregators
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores the mapping for a aggregate id to the calculator.
|
||||
/// </summary>
|
||||
private class FactoryMapping
|
||||
{
|
||||
public NodeId AggregateId { get; set; }
|
||||
|
||||
public QualifiedName AggregateName { get; set; }
|
||||
|
||||
public required Type Calculator { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mapping for all of the standard aggregates.
|
||||
/// </summary>
|
||||
private static readonly FactoryMapping[] s_mappings =
|
||||
[
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_Interpolative,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Interpolative),
|
||||
Calculator = typeof(AggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_Average,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Average),
|
||||
Calculator = typeof(AverageAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_TimeAverage,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_TimeAverage),
|
||||
Calculator = typeof(AverageAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_TimeAverage2,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_TimeAverage2),
|
||||
Calculator = typeof(AverageAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_Total,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Total),
|
||||
Calculator = typeof(AverageAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_Total2,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Total2),
|
||||
Calculator = typeof(AverageAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_Minimum,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Minimum),
|
||||
Calculator = typeof(MinMaxAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_Maximum,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Maximum),
|
||||
Calculator = typeof(MinMaxAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_MinimumActualTime,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_MinimumActualTime),
|
||||
Calculator = typeof(MinMaxAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_MaximumActualTime,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_MaximumActualTime),
|
||||
Calculator = typeof(MinMaxAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_Range,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Range),
|
||||
Calculator = typeof(MinMaxAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_Minimum2,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Minimum2),
|
||||
Calculator = typeof(MinMaxAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_Maximum2,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Maximum2),
|
||||
Calculator = typeof(MinMaxAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_MinimumActualTime2,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_MinimumActualTime2),
|
||||
Calculator = typeof(MinMaxAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_MaximumActualTime2,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_MaximumActualTime2),
|
||||
Calculator = typeof(MinMaxAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_Range2,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Range2),
|
||||
Calculator = typeof(MinMaxAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_Count,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Count),
|
||||
Calculator = typeof(CountAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_AnnotationCount,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_AnnotationCount),
|
||||
Calculator = typeof(CountAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_DurationInStateZero,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_DurationInStateZero),
|
||||
Calculator = typeof(CountAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_DurationInStateNonZero,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_DurationInStateNonZero),
|
||||
Calculator = typeof(CountAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_NumberOfTransitions,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_NumberOfTransitions),
|
||||
Calculator = typeof(CountAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_Start,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Start),
|
||||
Calculator = typeof(StartEndAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_End,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_End),
|
||||
Calculator = typeof(StartEndAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_Delta,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_Delta),
|
||||
Calculator = typeof(StartEndAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_StartBound,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_StartBound),
|
||||
Calculator = typeof(StartEndAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_EndBound,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_EndBound),
|
||||
Calculator = typeof(StartEndAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_DeltaBounds,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_DeltaBounds),
|
||||
Calculator = typeof(StartEndAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_DurationGood,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_DurationGood),
|
||||
Calculator = typeof(StatusAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_DurationBad,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_DurationBad),
|
||||
Calculator = typeof(StatusAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_PercentGood,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_PercentGood),
|
||||
Calculator = typeof(StatusAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_PercentBad,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_PercentBad),
|
||||
Calculator = typeof(StatusAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_WorstQuality,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_WorstQuality),
|
||||
Calculator = typeof(StatusAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_WorstQuality2,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_WorstQuality2),
|
||||
Calculator = typeof(StatusAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_StandardDeviationPopulation,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_StandardDeviationPopulation),
|
||||
Calculator = typeof(StdDevAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_VariancePopulation,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_VariancePopulation),
|
||||
Calculator = typeof(StdDevAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_StandardDeviationSample,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_StandardDeviationSample),
|
||||
Calculator = typeof(StdDevAggregateCalculator)
|
||||
},
|
||||
new FactoryMapping
|
||||
{
|
||||
AggregateId = ObjectIds.AggregateFunction_VarianceSample,
|
||||
AggregateName = QualifiedName.From(BrowseNames.AggregateFunction_VarianceSample),
|
||||
Calculator = typeof(StdDevAggregateCalculator)
|
||||
}
|
||||
];
|
||||
|
||||
internal static IEnumerable<NodeId> SupportedIds => s_mappings.Select(x => x.AggregateId);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the name for a standard aggregates.
|
||||
/// </summary>
|
||||
public static QualifiedName GetNameForStandardAggregate(NodeId aggregateId)
|
||||
{
|
||||
for (int ii = 0; ii < s_mappings.Length; ii++)
|
||||
{
|
||||
if (s_mappings[ii].AggregateId == aggregateId)
|
||||
{
|
||||
return s_mappings[ii].AggregateName;
|
||||
}
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the id for a standard aggregates.
|
||||
/// </summary>
|
||||
public static NodeId GetIdForStandardAggregate(QualifiedName aggregateName)
|
||||
{
|
||||
for (int ii = 0; ii < s_mappings.Length; ii++)
|
||||
{
|
||||
if (s_mappings[ii].AggregateName == aggregateName)
|
||||
{
|
||||
return s_mappings[ii].AggregateId;
|
||||
}
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a calculator for one of the standard aggregates.
|
||||
/// </summary>
|
||||
[Obsolete("Use CreateStandardCalculator with telemetry context.")]
|
||||
public static AggregateCalculator? CreateStandardCalculator(
|
||||
NodeId aggregateId,
|
||||
DateTime startTime,
|
||||
DateTime endTime,
|
||||
double processingInterval,
|
||||
bool stepped,
|
||||
AggregateConfiguration configuration)
|
||||
{
|
||||
return CreateStandardCalculator(
|
||||
aggregateId,
|
||||
startTime,
|
||||
endTime,
|
||||
processingInterval,
|
||||
stepped,
|
||||
configuration,
|
||||
null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a calculator for one of the standard aggregates.
|
||||
/// </summary>
|
||||
public static AggregateCalculator? CreateStandardCalculator(
|
||||
NodeId aggregateId,
|
||||
DateTime startTime,
|
||||
DateTime endTime,
|
||||
double processingInterval,
|
||||
bool stepped,
|
||||
AggregateConfiguration configuration,
|
||||
ITelemetryContext? telemetry)
|
||||
{
|
||||
for (int ii = 0; ii < s_mappings.Length; ii++)
|
||||
{
|
||||
if (s_mappings[ii].AggregateId == aggregateId)
|
||||
{
|
||||
return (AggregateCalculator?)
|
||||
Activator.CreateInstance(
|
||||
s_mappings[ii].Calculator,
|
||||
aggregateId,
|
||||
startTime,
|
||||
endTime,
|
||||
processingInterval,
|
||||
stepped,
|
||||
configuration,
|
||||
telemetry);
|
||||
}
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
}
|
||||
}
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
/* ========================================================================
|
||||
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
|
||||
*
|
||||
* OPC Foundation MIT License 1.00
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person
|
||||
* obtaining a copy of this software and associated documentation
|
||||
* files (the "Software"), to deal in the Software without
|
||||
* restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the
|
||||
* Software is furnished to do so, subject to the following
|
||||
* conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be
|
||||
* included in all copies or substantial portions of the Software.
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
* OTHER DEALINGS IN THE SOFTWARE.
|
||||
*
|
||||
* The complete license agreement can be found here:
|
||||
* http://opcfoundation.org/License/MIT/1.00/
|
||||
* ======================================================================*/
|
||||
|
||||
using Opc.Ua;
|
||||
using DataValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.LegacyDataValue;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the value of an aggregate.
|
||||
/// </summary>
|
||||
internal class AverageAggregateCalculator : AggregateCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the aggregate calculator.
|
||||
/// </summary>
|
||||
/// <param name="aggregateId">The aggregate function to apply.</param>
|
||||
/// <param name="startTime">The start time.</param>
|
||||
/// <param name="endTime">The end time.</param>
|
||||
/// <param name="processingInterval">The processing interval.</param>
|
||||
/// <param name="stepped">Whether to use stepped interpolation.</param>
|
||||
/// <param name="configuration">The aggregate configuration.</param>
|
||||
/// <param name="telemetry">The telemetry context to use to create obvservability instruments</param>
|
||||
public AverageAggregateCalculator(
|
||||
NodeId aggregateId,
|
||||
DateTime startTime,
|
||||
DateTime endTime,
|
||||
double processingInterval,
|
||||
bool stepped,
|
||||
AggregateConfiguration configuration,
|
||||
ITelemetryContext? telemetry)
|
||||
: base(aggregateId, startTime, endTime, processingInterval, stepped, configuration, telemetry)
|
||||
{
|
||||
SetPartialBit = aggregateId != ObjectIds.AggregateFunction_Average;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the value for the timeslice.
|
||||
/// </summary>
|
||||
protected override DataValue ComputeValue(TimeSlice slice)
|
||||
{
|
||||
uint? id = AggregateId.TryGetValue(out uint numericId) ? numericId : null;
|
||||
|
||||
if (id == null)
|
||||
{
|
||||
return base.ComputeValue(slice);
|
||||
}
|
||||
switch (id.Value)
|
||||
{
|
||||
case Objects.AggregateFunction_Average:
|
||||
return ComputeAverage(slice);
|
||||
case Objects.AggregateFunction_TimeAverage:
|
||||
return ComputeTimeAverage(slice, false, 1);
|
||||
case Objects.AggregateFunction_Total:
|
||||
return ComputeTimeAverage(slice, false, 2);
|
||||
case Objects.AggregateFunction_TimeAverage2:
|
||||
return ComputeTimeAverage(slice, true, 1);
|
||||
case Objects.AggregateFunction_Total2:
|
||||
return ComputeTimeAverage(slice, true, 2);
|
||||
default:
|
||||
return base.ComputeValue(slice);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the RegSlope, RegConst and RegStdDev aggregates for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeAverage(TimeSlice slice)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values = GetValues(slice);
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null || values.Count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
// calculate total and count.
|
||||
int count = 0;
|
||||
double total = 0;
|
||||
|
||||
for (int ii = 0; ii < values.Count; ii++)
|
||||
{
|
||||
if (StatusCode.IsGood(values[ii].StatusCode))
|
||||
{
|
||||
try
|
||||
{
|
||||
double sample = CastToDouble(values[ii]);
|
||||
total += sample;
|
||||
count++;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore conversion errors.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check for empty slice.
|
||||
if (count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
// select the result.
|
||||
double result = total / count;
|
||||
|
||||
// set the timestamp and status.
|
||||
var value = new DataValue
|
||||
{
|
||||
WrappedValue = SdkValueBoundary.FromClr(result),
|
||||
SourceTimestamp = GetTimestamp(slice),
|
||||
ServerTimestamp = GetTimestamp(slice)
|
||||
};
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
|
||||
value.StatusCode = GetValueBasedStatusCode(slice, values, value.StatusCode);
|
||||
|
||||
// return result.
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the StdDev, Variance, StdDev2 and Variance2 aggregates for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeTimeAverage(TimeSlice slice, bool useSimpleBounds, int valueType)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values;
|
||||
if (useSimpleBounds)
|
||||
{
|
||||
values = GetValuesWithSimpleBounds(slice);
|
||||
}
|
||||
else
|
||||
{
|
||||
values = GetValuesWithInterpolatedBounds(slice);
|
||||
}
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null || values.Count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
// get the regions.
|
||||
List<SubRegion> regions = GetRegionsInValueSet(values, !useSimpleBounds, Stepped);
|
||||
|
||||
double total = 0;
|
||||
double totalDuration = 0;
|
||||
bool nonGoodRegionsExists = false;
|
||||
|
||||
for (int ii = 0; ii < regions.Count; ii++)
|
||||
{
|
||||
double duration = regions[ii].Duration / 1000.0;
|
||||
|
||||
if (StatusCode.IsNotBad(regions[ii].StatusCode))
|
||||
{
|
||||
total += (regions[ii].StartValue + regions[ii].EndValue) * duration / 2;
|
||||
totalDuration += duration;
|
||||
}
|
||||
|
||||
if (StatusCode.IsNotGood(regions[ii].StatusCode))
|
||||
{
|
||||
nonGoodRegionsExists = true;
|
||||
}
|
||||
}
|
||||
|
||||
// check if no good data.
|
||||
if (totalDuration == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
// select the result.
|
||||
double result = 0;
|
||||
|
||||
switch (valueType)
|
||||
{
|
||||
case 1:
|
||||
result = total / totalDuration;
|
||||
break;
|
||||
case 2:
|
||||
result = total;
|
||||
break;
|
||||
default:
|
||||
Debug.Fail($"Unexpected value type {valueType}");
|
||||
break;
|
||||
}
|
||||
|
||||
// set the timestamp and status.
|
||||
var value = new DataValue
|
||||
{
|
||||
WrappedValue = SdkValueBoundary.FromClr(result),
|
||||
SourceTimestamp = GetTimestamp(slice),
|
||||
ServerTimestamp = GetTimestamp(slice)
|
||||
};
|
||||
|
||||
if (useSimpleBounds)
|
||||
{
|
||||
value.StatusCode = GetTimeBasedStatusCode(regions, value.StatusCode);
|
||||
}
|
||||
else
|
||||
{
|
||||
value.StatusCode = StatusCodes.Good;
|
||||
|
||||
if (nonGoodRegionsExists)
|
||||
{
|
||||
value.StatusCode = StatusCodes.UncertainDataSubNormal;
|
||||
}
|
||||
}
|
||||
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
|
||||
|
||||
// return result.
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates;
|
||||
|
||||
/// <summary>Adapts the versioned baseline algorithms to the SDK 2 calculator protocol without a second SDK runtime.</summary>
|
||||
internal static class CompatibilityAggregateFactory
|
||||
{
|
||||
internal static IAggregateCalculator Create(NodeId id, DateTimeUtc start, DateTimeUtc end, double interval,
|
||||
bool stepped, AggregateConfiguration configuration, ITelemetryContext telemetry) =>
|
||||
new Adapter(Aggregators.CreateStandardCalculator(id, (DateTime)start, (DateTime)end, interval, stepped, configuration, telemetry)
|
||||
?? throw new ArgumentOutOfRangeException(nameof(id), "Unsupported baseline aggregate."));
|
||||
|
||||
/// <summary>Replaces exactly the existing standard factories; no aggregate IDs are added or removed.</summary>
|
||||
internal static async ValueTask RegisterAsync(AggregateManager manager, CancellationToken ct)
|
||||
{
|
||||
foreach (var id in Aggregators.SupportedIds)
|
||||
await manager.RegisterFactoryAsync(id, Aggregators.GetNameForStandardAggregate(id).Name!, Create, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private sealed class Adapter(AggregateCalculator calculator) : IAggregateCalculator
|
||||
{
|
||||
public NodeId AggregateId => calculator.AggregateId;
|
||||
public bool QueueRawValue(Opc.Ua.DataValue value) => calculator.QueueRawValue(LegacyDataValue.FromSdk(value));
|
||||
public bool HasEndTimePassed(DateTimeUtc currentTime) => calculator.HasEndTimePassed((DateTime)currentTime);
|
||||
public bool TryGetProcessedValue(bool returnPartial, out Opc.Ua.DataValue value)
|
||||
{
|
||||
var processed = calculator.GetProcessedValue(returnPartial);
|
||||
value = processed is null ? default : processed.ToSdk();
|
||||
return processed is not null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
/* ========================================================================
|
||||
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
|
||||
*
|
||||
* OPC Foundation MIT License 1.00
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person
|
||||
* obtaining a copy of this software and associated documentation
|
||||
* files (the "Software"), to deal in the Software without
|
||||
* restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the
|
||||
* Software is furnished to do so, subject to the following
|
||||
* conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be
|
||||
* included in all copies or substantial portions of the Software.
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
* OTHER DEALINGS IN THE SOFTWARE.
|
||||
*
|
||||
* The complete license agreement can be found here:
|
||||
* http://opcfoundation.org/License/MIT/1.00/
|
||||
* ======================================================================*/
|
||||
|
||||
using Opc.Ua;
|
||||
using DataValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.LegacyDataValue;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the value of an aggregate.
|
||||
/// </summary>
|
||||
internal class CountAggregateCalculator : AggregateCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the aggregate calculator.
|
||||
/// </summary>
|
||||
/// <param name="aggregateId">The aggregate function to apply.</param>
|
||||
/// <param name="startTime">The start time.</param>
|
||||
/// <param name="endTime">The end time.</param>
|
||||
/// <param name="processingInterval">The processing interval.</param>
|
||||
/// <param name="stepped">Whether to use stepped interpolation.</param>
|
||||
/// <param name="configuration">The aggregate configuration.</param>
|
||||
/// <param name="telemetry">The telemetry context to use to create obvservability instruments</param>
|
||||
public CountAggregateCalculator(
|
||||
NodeId aggregateId,
|
||||
DateTime startTime,
|
||||
DateTime endTime,
|
||||
double processingInterval,
|
||||
bool stepped,
|
||||
AggregateConfiguration configuration,
|
||||
ITelemetryContext? telemetry)
|
||||
: base(aggregateId, startTime, endTime, processingInterval, stepped, configuration, telemetry)
|
||||
{
|
||||
SetPartialBit = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the value for the timeslice.
|
||||
/// </summary>
|
||||
protected override DataValue ComputeValue(TimeSlice slice)
|
||||
{
|
||||
uint? id = AggregateId.TryGetValue(out uint numericId) ? numericId : null;
|
||||
|
||||
if (id == null)
|
||||
{
|
||||
return base.ComputeValue(slice);
|
||||
}
|
||||
switch (id.Value)
|
||||
{
|
||||
case Objects.AggregateFunction_Count:
|
||||
return ComputeCount(slice);
|
||||
case Objects.AggregateFunction_AnnotationCount:
|
||||
return ComputeAnnotationCount(slice);
|
||||
case Objects.AggregateFunction_DurationInStateZero:
|
||||
return ComputeDurationInState(slice, false);
|
||||
case Objects.AggregateFunction_DurationInStateNonZero:
|
||||
return ComputeDurationInState(slice, true);
|
||||
case Objects.AggregateFunction_NumberOfTransitions:
|
||||
return ComputeNumberOfTransitions(slice);
|
||||
default:
|
||||
return base.ComputeValue(slice);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the Count aggregate for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeCount(TimeSlice slice)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values = GetValues(slice);
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
// count the values.
|
||||
int count = 0;
|
||||
|
||||
for (int ii = 0; ii < values.Count; ii++)
|
||||
{
|
||||
if (StatusCode.IsGood(values[ii].StatusCode))
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
// set the timestamp and status.
|
||||
var value = new DataValue
|
||||
{
|
||||
WrappedValue = SdkValueBoundary.FromClr(count),
|
||||
SourceTimestamp = GetTimestamp(slice),
|
||||
ServerTimestamp = GetTimestamp(slice)
|
||||
};
|
||||
value.StatusCode = GetValueBasedStatusCode(slice, values, value.StatusCode);
|
||||
|
||||
if (!StatusCode.IsBad(value.StatusCode))
|
||||
{
|
||||
// set aggregate bits fon non Bad values
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
|
||||
}
|
||||
// return result.
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the AnnotationCount aggregate for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeAnnotationCount(TimeSlice slice)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values = GetValues(slice);
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
// count the values.
|
||||
int count = 0;
|
||||
|
||||
for (int ii = 0; ii < values.Count; ii++)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
|
||||
// set the timestamp and status.
|
||||
var value = new DataValue
|
||||
{
|
||||
WrappedValue = SdkValueBoundary.FromClr(count),
|
||||
SourceTimestamp = GetTimestamp(slice),
|
||||
ServerTimestamp = GetTimestamp(slice)
|
||||
};
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
|
||||
|
||||
// return result.
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the DurationInStateZero and DurationInStateNonZero aggregates for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeDurationInState(TimeSlice slice, bool isNonZero)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values = GetValuesWithSimpleBounds(slice);
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
// get the regions.
|
||||
List<SubRegion> regions = GetRegionsInValueSet(values, false, true);
|
||||
|
||||
double duration = 0;
|
||||
|
||||
for (int ii = 0; ii < regions.Count; ii++)
|
||||
{
|
||||
if (StatusCode.IsNotGood(regions[ii].StatusCode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isNonZero)
|
||||
{
|
||||
if (regions[ii].StartValue != 0)
|
||||
{
|
||||
duration += regions[ii].Duration;
|
||||
}
|
||||
}
|
||||
else if (regions[ii].StartValue == 0)
|
||||
{
|
||||
duration += regions[ii].Duration;
|
||||
}
|
||||
}
|
||||
|
||||
// set the timestamp and status.
|
||||
var value = new DataValue
|
||||
{
|
||||
WrappedValue = SdkValueBoundary.FromClr(duration),
|
||||
SourceTimestamp = GetTimestamp(slice),
|
||||
ServerTimestamp = GetTimestamp(slice)
|
||||
};
|
||||
value.StatusCode = GetTimeBasedStatusCode(regions, value.StatusCode);
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
|
||||
|
||||
// return result.
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the Count aggregate for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeNumberOfTransitions(TimeSlice slice)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values = GetValues(slice);
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
// The first non-bad value is a transition when no previous non-bad value exists.
|
||||
LinkedListNode<DataValue>? previousValue = slice.NonBadEarlyBound;
|
||||
bool hasLastValue = previousValue != null;
|
||||
object? lastValue = previousValue?.Value.Value;
|
||||
|
||||
// count the transitions.
|
||||
int count = 0;
|
||||
|
||||
for (int ii = 0; ii < values.Count; ii++)
|
||||
{
|
||||
if (StatusCode.IsBad(values[ii].StatusCode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
object? nextValue = values[ii].Value;
|
||||
|
||||
if (!hasLastValue || !Utils.IsEqual(lastValue, nextValue))
|
||||
{
|
||||
count++;
|
||||
}
|
||||
|
||||
hasLastValue = true;
|
||||
lastValue = nextValue;
|
||||
}
|
||||
|
||||
// set the timestamp and status.
|
||||
var value = new DataValue
|
||||
{
|
||||
WrappedValue = SdkValueBoundary.FromClr(count),
|
||||
SourceTimestamp = GetTimestamp(slice),
|
||||
ServerTimestamp = GetTimestamp(slice)
|
||||
};
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
|
||||
value.StatusCode = GetValueBasedStatusCode(slice, values, value.StatusCode);
|
||||
|
||||
// return result.
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Opc.Ua;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates;
|
||||
|
||||
/// <summary>Private mutable work value preserving the baseline calculator's ownership and timestamp semantics.</summary>
|
||||
internal sealed class LegacyDataValue
|
||||
{
|
||||
internal LegacyDataValue() { }
|
||||
internal LegacyDataValue(LegacyDataValue other)
|
||||
{
|
||||
WrappedValue = other.WrappedValue;
|
||||
StatusCode = other.StatusCode;
|
||||
SourceTimestamp = other.SourceTimestamp;
|
||||
ServerTimestamp = other.ServerTimestamp;
|
||||
SourcePicoseconds = other.SourcePicoseconds;
|
||||
ServerPicoseconds = other.ServerPicoseconds;
|
||||
}
|
||||
internal LegacyDataValue(Variant value, StatusCode status, DateTime source, DateTime server)
|
||||
{
|
||||
WrappedValue = value; StatusCode = status; SourceTimestamp = source; ServerTimestamp = server;
|
||||
}
|
||||
internal Variant WrappedValue { get; set; }
|
||||
internal object? Value { get => SdkValueBoundary.ToClr(WrappedValue); set => WrappedValue = SdkValueBoundary.FromClr(value); }
|
||||
internal StatusCode StatusCode { get; set; } = StatusCodes.Good;
|
||||
internal DateTime SourceTimestamp { get; set; }
|
||||
internal DateTime ServerTimestamp { get; set; }
|
||||
internal ushort SourcePicoseconds { get; set; }
|
||||
internal ushort ServerPicoseconds { get; set; }
|
||||
internal static LegacyDataValue FromSdk(Opc.Ua.DataValue value) => new()
|
||||
{
|
||||
WrappedValue = value.WrappedValue, StatusCode = value.StatusCode,
|
||||
SourceTimestamp = (DateTime)value.SourceTimestamp, ServerTimestamp = (DateTime)value.ServerTimestamp,
|
||||
SourcePicoseconds = value.SourcePicoseconds, ServerPicoseconds = value.ServerPicoseconds,
|
||||
};
|
||||
internal Opc.Ua.DataValue ToSdk() => new(WrappedValue, StatusCode, SourceTimestamp, ServerTimestamp, SourcePicoseconds, ServerPicoseconds);
|
||||
}
|
||||
+505
@@ -0,0 +1,505 @@
|
||||
/* ========================================================================
|
||||
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
|
||||
*
|
||||
* OPC Foundation MIT License 1.00
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person
|
||||
* obtaining a copy of this software and associated documentation
|
||||
* files (the "Software"), to deal in the Software without
|
||||
* restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the
|
||||
* Software is furnished to do so, subject to the following
|
||||
* conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be
|
||||
* included in all copies or substantial portions of the Software.
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
* OTHER DEALINGS IN THE SOFTWARE.
|
||||
*
|
||||
* The complete license agreement can be found here:
|
||||
* http://opcfoundation.org/License/MIT/1.00/
|
||||
* ======================================================================*/
|
||||
|
||||
using Opc.Ua;
|
||||
using DataValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.LegacyDataValue;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the value of an aggregate.
|
||||
/// </summary>
|
||||
internal class MinMaxAggregateCalculator : AggregateCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the aggregate calculator.
|
||||
/// </summary>
|
||||
/// <param name="aggregateId">The aggregate function to apply.</param>
|
||||
/// <param name="startTime">The start time.</param>
|
||||
/// <param name="endTime">The end time.</param>
|
||||
/// <param name="processingInterval">The processing interval.</param>
|
||||
/// <param name="stepped">Whether to use stepped interpolation.</param>
|
||||
/// <param name="configuration">The aggregate configuration.</param>
|
||||
/// <param name="telemetry">The telemetry context to use to create obvservability instruments</param>
|
||||
public MinMaxAggregateCalculator(
|
||||
NodeId aggregateId,
|
||||
DateTime startTime,
|
||||
DateTime endTime,
|
||||
double processingInterval,
|
||||
bool stepped,
|
||||
AggregateConfiguration configuration,
|
||||
ITelemetryContext? telemetry)
|
||||
: base(aggregateId, startTime, endTime, processingInterval, stepped, configuration, telemetry)
|
||||
{
|
||||
SetPartialBit = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the value for the timeslice.
|
||||
/// </summary>
|
||||
protected override DataValue ComputeValue(TimeSlice slice)
|
||||
{
|
||||
uint? id = AggregateId.TryGetValue(out uint numericId) ? numericId : null;
|
||||
|
||||
if (id == null)
|
||||
{
|
||||
return base.ComputeValue(slice);
|
||||
}
|
||||
switch (id.Value)
|
||||
{
|
||||
case Objects.AggregateFunction_Minimum:
|
||||
return ComputeMinMax(slice, 1, false);
|
||||
case Objects.AggregateFunction_MinimumActualTime:
|
||||
return ComputeMinMax(slice, 1, true);
|
||||
case Objects.AggregateFunction_Maximum:
|
||||
return ComputeMinMax(slice, 2, false);
|
||||
case Objects.AggregateFunction_MaximumActualTime:
|
||||
return ComputeMinMax(slice, 2, true);
|
||||
case Objects.AggregateFunction_Range:
|
||||
return ComputeMinMax(slice, 3, false);
|
||||
case Objects.AggregateFunction_Minimum2:
|
||||
return ComputeMinMax2(slice, 1, false);
|
||||
case Objects.AggregateFunction_MinimumActualTime2:
|
||||
return ComputeMinMax2(slice, 1, true);
|
||||
case Objects.AggregateFunction_Maximum2:
|
||||
return ComputeMinMax2(slice, 2, false);
|
||||
case Objects.AggregateFunction_MaximumActualTime2:
|
||||
return ComputeMinMax2(slice, 2, true);
|
||||
case Objects.AggregateFunction_Range2:
|
||||
return ComputeMinMax2(slice, 3, false);
|
||||
default:
|
||||
return base.ComputeValue(slice);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate the Minimum, Maximum, MinimumActualTime and MaximumActualTime aggregates for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeMinMax(TimeSlice slice, int valueType, bool returnActualTime)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values = GetValues(slice);
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null || values.Count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
double minimumGoodValue = double.MaxValue;
|
||||
double minimumUncertainValue = double.MaxValue;
|
||||
double maximumGoodValue = double.MinValue;
|
||||
double maximumUncertainValue = double.MinValue;
|
||||
|
||||
DateTime minimumGoodTimestamp = DateTime.MinValue;
|
||||
DateTime maximumGoodTimestamp = DateTime.MinValue;
|
||||
|
||||
TypeInfo? minimumOriginalType = null;
|
||||
TypeInfo? maximumOriginalType = null;
|
||||
|
||||
bool badValuesExist = false;
|
||||
bool duplicatesMinimumsExist = false;
|
||||
bool duplicatesMaximumsExist = false;
|
||||
bool goodValueExists = false;
|
||||
|
||||
for (int ii = 0; ii < values.Count; ii++)
|
||||
{
|
||||
DateTime currentTime = values[ii].SourceTimestamp;
|
||||
StatusCode currentStatus = values[ii].StatusCode;
|
||||
|
||||
// ignore bad values.
|
||||
if (!IsGood(values[ii]))
|
||||
{
|
||||
badValuesExist = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
double currentValue;
|
||||
// convert to double.
|
||||
try
|
||||
{
|
||||
currentValue = CastToDouble(values[ii]);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
badValuesExist = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// check for uncertain.
|
||||
if (StatusCode.IsUncertain(currentStatus))
|
||||
{
|
||||
if (minimumUncertainValue > currentValue)
|
||||
{
|
||||
minimumUncertainValue = currentValue;
|
||||
}
|
||||
|
||||
if (maximumUncertainValue < currentValue)
|
||||
{
|
||||
maximumUncertainValue = currentValue;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// check for new minimum.
|
||||
if (minimumGoodValue > currentValue)
|
||||
{
|
||||
minimumGoodValue = currentValue;
|
||||
minimumGoodTimestamp = currentTime;
|
||||
minimumOriginalType = values[ii].WrappedValue.TypeInfo;
|
||||
duplicatesMinimumsExist = false;
|
||||
goodValueExists = true;
|
||||
}
|
||||
// check for duplicate minimums.
|
||||
else if (minimumGoodValue == currentValue)
|
||||
{
|
||||
duplicatesMinimumsExist = true;
|
||||
}
|
||||
|
||||
// check for new maximum.
|
||||
if (maximumGoodValue < currentValue)
|
||||
{
|
||||
maximumGoodValue = currentValue;
|
||||
maximumGoodTimestamp = currentTime;
|
||||
maximumOriginalType = values[ii].WrappedValue.TypeInfo;
|
||||
duplicatesMaximumsExist = false;
|
||||
goodValueExists = true;
|
||||
}
|
||||
// check for duplicate maximums.
|
||||
else if (maximumGoodValue == currentValue)
|
||||
{
|
||||
duplicatesMaximumsExist = true;
|
||||
}
|
||||
}
|
||||
|
||||
// check if at least one good value exists.
|
||||
if (!goodValueExists)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
// set the status code.
|
||||
StatusCode statusCode = StatusCodes.Good;
|
||||
|
||||
// uncertain if any bad values exist.
|
||||
if (badValuesExist)
|
||||
{
|
||||
statusCode = StatusCodes.UncertainDataSubNormal;
|
||||
}
|
||||
|
||||
// determine the calculated value to return.
|
||||
object? processedValue = null;
|
||||
TypeInfo? processedType = null;
|
||||
DateTime processedTimestamp = DateTime.MinValue;
|
||||
bool duplicatesExist = false;
|
||||
|
||||
if (valueType == 1)
|
||||
{
|
||||
processedValue = minimumGoodValue;
|
||||
processedTimestamp = minimumGoodTimestamp;
|
||||
processedType = minimumOriginalType;
|
||||
_ = minimumGoodValue > minimumUncertainValue;
|
||||
duplicatesExist = duplicatesMinimumsExist;
|
||||
}
|
||||
else if (valueType == 2)
|
||||
{
|
||||
processedValue = maximumGoodValue;
|
||||
processedTimestamp = maximumGoodTimestamp;
|
||||
processedType = maximumOriginalType;
|
||||
_ = maximumGoodValue < maximumUncertainValue;
|
||||
duplicatesExist = duplicatesMaximumsExist;
|
||||
}
|
||||
else if (valueType == 3)
|
||||
{
|
||||
processedValue = Math.Abs(maximumGoodValue - minimumGoodValue);
|
||||
processedType = TypeInfo.Scalars.Double;
|
||||
_ = maximumGoodValue < maximumUncertainValue ||
|
||||
minimumGoodValue > minimumUncertainValue;
|
||||
}
|
||||
|
||||
// set calculated if not returning actual time and value is not at the start time.
|
||||
if (!returnActualTime && processedTimestamp != GetTimestamp(slice))
|
||||
{
|
||||
statusCode = statusCode.WithAggregateBits(AggregateBits.Calculated);
|
||||
}
|
||||
|
||||
// set the multiple values flags.
|
||||
if (duplicatesExist)
|
||||
{
|
||||
statusCode = statusCode.WithAggregateBits(
|
||||
statusCode.AggregateBits | AggregateBits.MultipleValues);
|
||||
}
|
||||
|
||||
// convert back to original datatype.
|
||||
if (processedType != null && processedType.Value.BuiltInType != BuiltInType.Double)
|
||||
{
|
||||
processedValue = LegacyCast(
|
||||
processedValue,
|
||||
TypeInfo.Scalars.Double,
|
||||
processedType.Value.BuiltInType);
|
||||
}
|
||||
else
|
||||
{
|
||||
processedType = TypeInfo.Scalars.Double;
|
||||
}
|
||||
|
||||
// create processed value.
|
||||
var value = new DataValue
|
||||
{
|
||||
WrappedValue = SdkValueBoundary.FromClr(processedValue),
|
||||
StatusCode = statusCode
|
||||
};
|
||||
|
||||
if (returnActualTime)
|
||||
{
|
||||
value.SourceTimestamp = processedTimestamp;
|
||||
value.ServerTimestamp = processedTimestamp;
|
||||
}
|
||||
else
|
||||
{
|
||||
value.SourceTimestamp = GetTimestamp(slice);
|
||||
value.ServerTimestamp = GetTimestamp(slice);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate the Minimum2, Maximum2, MinimumActualTime2, MaximumActualTime2 and Range2 aggregates for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeMinMax2(TimeSlice slice, int valueType, bool returnActualTime)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values = GetValuesWithSimpleBounds(slice);
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null || values.Count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
double minimumGoodValue = double.MaxValue;
|
||||
double maximumGoodValue = double.MinValue;
|
||||
|
||||
DateTime minimumGoodTimestamp = DateTime.MinValue;
|
||||
DateTime maximumGoodTimestamp = DateTime.MinValue;
|
||||
|
||||
StatusCode minimumGoodStatusCode = StatusCodes.Good;
|
||||
StatusCode maximumGoodStatusCode = StatusCodes.Good;
|
||||
|
||||
TypeInfo? minimumOriginalType = null;
|
||||
TypeInfo? maximumOriginalType = null;
|
||||
|
||||
bool duplicatesMinimumsExist = false;
|
||||
bool duplicatesMaximumsExist = false;
|
||||
bool goodValueExists = false;
|
||||
|
||||
for (int ii = 0; ii < values.Count; ii++)
|
||||
{
|
||||
DateTime currentTime = values[ii].SourceTimestamp;
|
||||
StatusCode currentStatus = values[ii].StatusCode;
|
||||
|
||||
// ignore bad values (as determined by the TreatUncertainAsBad parameter).
|
||||
if (!IsGood(values[ii]))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
double currentValue;
|
||||
// convert to double.
|
||||
try
|
||||
{
|
||||
currentValue = CastToDouble(values[ii]);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// skip endpoint if stepped.
|
||||
if (currentTime == slice.EndTime && Stepped)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// check for new minimum.
|
||||
if (minimumGoodValue > currentValue)
|
||||
{
|
||||
minimumGoodValue = currentValue;
|
||||
minimumGoodTimestamp = currentTime;
|
||||
minimumGoodStatusCode = currentStatus;
|
||||
minimumOriginalType = values[ii].WrappedValue.TypeInfo;
|
||||
duplicatesMinimumsExist = false;
|
||||
goodValueExists = true;
|
||||
}
|
||||
// check for duplicate minimums.
|
||||
else if (minimumGoodValue == currentValue)
|
||||
{
|
||||
duplicatesMinimumsExist = true;
|
||||
}
|
||||
|
||||
// check for new maximum.
|
||||
if (maximumGoodValue < currentValue)
|
||||
{
|
||||
maximumGoodValue = currentValue;
|
||||
maximumGoodTimestamp = currentTime;
|
||||
maximumGoodStatusCode = currentStatus;
|
||||
maximumOriginalType = values[ii].WrappedValue.TypeInfo;
|
||||
duplicatesMaximumsExist = false;
|
||||
goodValueExists = true;
|
||||
}
|
||||
// check for duplicate maximums.
|
||||
else if (maximumGoodValue == currentValue)
|
||||
{
|
||||
duplicatesMaximumsExist = true;
|
||||
}
|
||||
}
|
||||
|
||||
// check if at least one good value exists.
|
||||
if (!goodValueExists)
|
||||
{
|
||||
DataValue noDataValue = GetNoDataValue(slice);
|
||||
// check if interval is partial and set the flag accordingly
|
||||
if (slice.Partial)
|
||||
{
|
||||
noDataValue.StatusCode = noDataValue.StatusCode
|
||||
.WithAggregateBits(AggregateBits.Partial);
|
||||
}
|
||||
return noDataValue;
|
||||
}
|
||||
|
||||
// determine the calculated value to return.
|
||||
object? processedValue = null;
|
||||
TypeInfo? processedType = null;
|
||||
DateTime processedTimestamp = DateTime.MinValue;
|
||||
StatusCode processedStatusCode = StatusCodes.Good;
|
||||
bool duplicatesExist = false;
|
||||
|
||||
if (valueType == 1)
|
||||
{
|
||||
processedValue = minimumGoodValue;
|
||||
processedTimestamp = minimumGoodTimestamp;
|
||||
processedStatusCode = minimumGoodStatusCode;
|
||||
processedType = minimumOriginalType;
|
||||
duplicatesExist = duplicatesMinimumsExist;
|
||||
}
|
||||
else if (valueType == 2)
|
||||
{
|
||||
processedValue = maximumGoodValue;
|
||||
processedTimestamp = maximumGoodTimestamp;
|
||||
processedStatusCode = maximumGoodStatusCode;
|
||||
processedType = maximumOriginalType;
|
||||
duplicatesExist = duplicatesMaximumsExist;
|
||||
}
|
||||
else if (valueType == 3)
|
||||
{
|
||||
processedValue = Math.Abs(maximumGoodValue - minimumGoodValue);
|
||||
processedType = TypeInfo.Scalars.Double;
|
||||
}
|
||||
|
||||
// set the status code.
|
||||
StatusCode statusCode = processedStatusCode;
|
||||
|
||||
// set calculated if not returning actual time and value is not at the start time.
|
||||
if (!returnActualTime &&
|
||||
processedTimestamp != GetTimestamp(slice) &&
|
||||
(statusCode.AggregateBits & AggregateBits.Interpolated) == 0)
|
||||
{
|
||||
statusCode = statusCode.WithAggregateBits(
|
||||
statusCode.AggregateBits | AggregateBits.Calculated);
|
||||
}
|
||||
|
||||
// set the multiple values flags.
|
||||
if (duplicatesExist)
|
||||
{
|
||||
statusCode = statusCode.WithAggregateBits(
|
||||
statusCode.AggregateBits | AggregateBits.MultipleValues);
|
||||
}
|
||||
|
||||
// convert back to original datatype.
|
||||
if (processedType != null && processedType.Value.BuiltInType != BuiltInType.Double)
|
||||
{
|
||||
processedValue = LegacyCast(
|
||||
processedValue,
|
||||
TypeInfo.Scalars.Double,
|
||||
processedType.Value.BuiltInType);
|
||||
}
|
||||
else
|
||||
{
|
||||
processedType = TypeInfo.Scalars.Double;
|
||||
}
|
||||
|
||||
// create processed value.
|
||||
var value = new DataValue
|
||||
{
|
||||
WrappedValue = SdkValueBoundary.FromClr(processedValue),
|
||||
StatusCode = GetTimeBasedStatusCode(slice, values, statusCode)
|
||||
};
|
||||
|
||||
// zero value if status is bad.
|
||||
if (StatusCode.IsBad(value.StatusCode))
|
||||
{
|
||||
value.WrappedValue = Variant.Null;
|
||||
}
|
||||
|
||||
if (returnActualTime)
|
||||
{
|
||||
// calculate effective time if end bound is used.
|
||||
if (TimeFlowsBackward)
|
||||
{
|
||||
if (processedTimestamp == slice.StartTime)
|
||||
{
|
||||
processedTimestamp = processedTimestamp.AddMilliseconds(+1);
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(
|
||||
value.StatusCode.AggregateBits | AggregateBits.Interpolated);
|
||||
}
|
||||
}
|
||||
else if (processedTimestamp == slice.EndTime)
|
||||
{
|
||||
processedTimestamp = processedTimestamp.AddMilliseconds(-1);
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(
|
||||
value.StatusCode.AggregateBits | AggregateBits.Interpolated);
|
||||
}
|
||||
|
||||
value.SourceTimestamp = processedTimestamp;
|
||||
value.ServerTimestamp = processedTimestamp;
|
||||
}
|
||||
else
|
||||
{
|
||||
value.SourceTimestamp = GetTimestamp(slice);
|
||||
value.ServerTimestamp = GetTimestamp(slice);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Preserved aggregate calculations
|
||||
|
||||
The SDK 2.0 upgrade keeps the application's existing historian and live AggregateFilter results (owner decision, 2026-11-10, #782). SDK 2.0.1 changes more than the default configuration: uncertain-sample selection, value-based quality, reverse data edges, partial results and interpolation differ. Changing TreatUncertainAsBad alone does not preserve the contract.
|
||||
|
||||
The eight upstream algorithm/factory files here originate at OPCFoundation/UA-.NETStandard tag `1.5.378.182`. Original MIT license headers remain. `baseline-sources.json` records each original URL and SHA-256. They are application-private source, not an old SDK runtime or migration shim.
|
||||
|
||||
Representation adaptations:
|
||||
|
||||
- Private `LegacyDataValue` retains mutable intermediate work values and CLR DateTime timestamps; the adapter accepts/emits immutable SDK 2 values, including status and picoseconds.
|
||||
- Nullable reference annotations describe the existing optional bounds/slices; no warning policy is disabled.
|
||||
- Immutable StatusCode methods replace the old setters. NodeId extraction and QualifiedName construction use supported typed APIs.
|
||||
- Removed TypeInfo.Cast/Variant(object, TypeInfo) operations use the SDK 2 typed conversion surface; baseline vectors verify value **types**, values, timestamps and all status bits.
|
||||
- Interval, quality, sample selection, queue trimming, interpolation and partial-result algorithms remain the baseline algorithms. Factory registration replaces the existing 37 IDs through the public SDK API; it must not add capabilities or duplicate advertised references.
|
||||
|
||||
The isolated `scripts/validation/opcua-aggregate-baseline` process pins only SDK 1.5.378.182 and generates the checked-in golden fixture. It is deliberately outside the solution and does not inherit the candidate package graph. The golden file records the baseline assembly hash, shared inputs and 4,144 outputs/cases across all 37 aggregate IDs, seven datasets, forward/reverse, stepped/sloped interpolation, uncertain-as-bad flags and incremental/partial processing. No baseline binary is loaded by the candidate tests or production host.
|
||||
|
||||
Run the generator with a **new** output path, then compare it with `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Fixtures/opcua-1.5.378.182-aggregates.json`. It refuses to overwrite evidence. Never regenerate expectations from the compatibility implementation. Existing historian parity tests retain their application-specific documented divergences from the baseline SDK, while new compatibility tests check the complete old-calculator output.
|
||||
|
||||
This preserves older behavior deliberately; it does not claim that all older SDK algorithms implement the newest OPC UA interpretation. A future semantic migration needs its own consumer-impact decision and versioned tests.
|
||||
+355
@@ -0,0 +1,355 @@
|
||||
/* ========================================================================
|
||||
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
|
||||
*
|
||||
* OPC Foundation MIT License 1.00
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person
|
||||
* obtaining a copy of this software and associated documentation
|
||||
* files (the "Software"), to deal in the Software without
|
||||
* restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the
|
||||
* Software is furnished to do so, subject to the following
|
||||
* conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be
|
||||
* included in all copies or substantial portions of the Software.
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
* OTHER DEALINGS IN THE SOFTWARE.
|
||||
*
|
||||
* The complete license agreement can be found here:
|
||||
* http://opcfoundation.org/License/MIT/1.00/
|
||||
* ======================================================================*/
|
||||
|
||||
using Opc.Ua;
|
||||
using DataValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.LegacyDataValue;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the value of an aggregate.
|
||||
/// </summary>
|
||||
internal class StartEndAggregateCalculator : AggregateCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the aggregate calculator.
|
||||
/// </summary>
|
||||
/// <param name="aggregateId">The aggregate function to apply.</param>
|
||||
/// <param name="startTime">The start time.</param>
|
||||
/// <param name="endTime">The end time.</param>
|
||||
/// <param name="processingInterval">The processing interval.</param>
|
||||
/// <param name="stepped">Whether to use stepped interpolation.</param>
|
||||
/// <param name="configuration">The aggregate configuration.</param>
|
||||
/// <param name="telemetry">The telemetry context to use to create obvservability instruments</param>
|
||||
public StartEndAggregateCalculator(
|
||||
NodeId aggregateId,
|
||||
DateTime startTime,
|
||||
DateTime endTime,
|
||||
double processingInterval,
|
||||
bool stepped,
|
||||
AggregateConfiguration configuration,
|
||||
ITelemetryContext? telemetry)
|
||||
: base(aggregateId, startTime, endTime, processingInterval, stepped, configuration, telemetry)
|
||||
{
|
||||
SetPartialBit = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the value for the timeslice.
|
||||
/// </summary>
|
||||
protected override DataValue ComputeValue(TimeSlice slice)
|
||||
{
|
||||
uint? id = AggregateId.TryGetValue(out uint numericId) ? numericId : null;
|
||||
|
||||
if (id == null)
|
||||
{
|
||||
return base.ComputeValue(slice);
|
||||
}
|
||||
switch (id.Value)
|
||||
{
|
||||
case Objects.AggregateFunction_Start:
|
||||
return ComputeStartEnd(slice, false);
|
||||
case Objects.AggregateFunction_End:
|
||||
return ComputeStartEnd(slice, true);
|
||||
case Objects.AggregateFunction_Delta:
|
||||
return ComputeDelta(slice);
|
||||
case Objects.AggregateFunction_StartBound:
|
||||
return ComputeStartEnd2(slice, false);
|
||||
case Objects.AggregateFunction_EndBound:
|
||||
return ComputeStartEnd2(slice, true);
|
||||
case Objects.AggregateFunction_DeltaBounds:
|
||||
return ComputeDelta2(slice);
|
||||
default:
|
||||
return base.ComputeValue(slice);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate the Start and End aggregates for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeStartEnd(TimeSlice slice, bool returnEnd)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values = GetValues(slice);
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null || values.Count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
// return start value.
|
||||
if (!returnEnd)
|
||||
{
|
||||
return values[0];
|
||||
}
|
||||
|
||||
return values[^1];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the Delta aggregate for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeDelta(TimeSlice slice)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values = GetValues(slice);
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null || values.Count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
double startValue = double.NaN;
|
||||
TypeInfo? originalType = null;
|
||||
bool badDataSkipped = false;
|
||||
|
||||
for (int ii = 0; ii < values.Count; ii++)
|
||||
{
|
||||
// find start value.
|
||||
DataValue start = values[ii];
|
||||
|
||||
if (IsGood(start))
|
||||
{
|
||||
try
|
||||
{
|
||||
startValue = CastToDouble(start);
|
||||
originalType = start.WrappedValue.TypeInfo;
|
||||
break;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
startValue = double.NaN;
|
||||
}
|
||||
}
|
||||
|
||||
badDataSkipped = true;
|
||||
}
|
||||
|
||||
double endValue = double.NaN;
|
||||
|
||||
for (int ii = values.Count - 1; ii >= 0; ii--)
|
||||
{
|
||||
// find end value.
|
||||
DataValue end = values[ii];
|
||||
|
||||
if (IsGood(end))
|
||||
{
|
||||
try
|
||||
{
|
||||
endValue = CastToDouble(end);
|
||||
break;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
endValue = double.NaN;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
badDataSkipped = true;
|
||||
}
|
||||
|
||||
// check if no good data.
|
||||
if (double.IsNaN(startValue) || double.IsNaN(endValue))
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
var value = new DataValue
|
||||
{
|
||||
SourceTimestamp = GetTimestamp(slice),
|
||||
ServerTimestamp = GetTimestamp(slice)
|
||||
};
|
||||
|
||||
// set status code.
|
||||
if (badDataSkipped)
|
||||
{
|
||||
value.StatusCode = StatusCodes.UncertainDataSubNormal;
|
||||
}
|
||||
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
|
||||
|
||||
// calculate delta.
|
||||
double delta = endValue - startValue;
|
||||
|
||||
if (originalType != null && originalType.Value.BuiltInType != BuiltInType.Double)
|
||||
{
|
||||
object delta2 = LegacyCast(
|
||||
delta,
|
||||
TypeInfo.Scalars.Double,
|
||||
originalType.Value.BuiltInType);
|
||||
value.WrappedValue = SdkValueBoundary.FromClr(delta2);
|
||||
}
|
||||
else
|
||||
{
|
||||
value.WrappedValue = SdkValueBoundary.FromClr(delta);
|
||||
}
|
||||
|
||||
// return result.
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate the Start2 and End2 aggregates for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeStartEnd2(TimeSlice slice, bool returnEnd)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values = GetValuesWithSimpleBounds(slice);
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null || values.Count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
DataValue value;
|
||||
|
||||
// return start bound.
|
||||
if ((!returnEnd && !TimeFlowsBackward) || (returnEnd && TimeFlowsBackward))
|
||||
{
|
||||
value = values[0];
|
||||
}
|
||||
// return end bound.
|
||||
else
|
||||
{
|
||||
value = values[^1];
|
||||
}
|
||||
|
||||
if (!IsGood(value))
|
||||
{
|
||||
value.StatusCode = StatusCodes.BadNoData;
|
||||
}
|
||||
|
||||
if (returnEnd)
|
||||
{
|
||||
value.SourceTimestamp = GetTimestamp(slice);
|
||||
value.ServerTimestamp = GetTimestamp(slice);
|
||||
|
||||
if (StatusCode.IsNotBad(value.StatusCode))
|
||||
{
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the Delta2 aggregate for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeDelta2(TimeSlice slice)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values = GetValuesWithSimpleBounds(slice);
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null || values.Count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
DataValue start = values[0];
|
||||
DataValue end = values[^1];
|
||||
|
||||
// check for bad bounds.
|
||||
if (!IsGood(start) || !IsGood(end))
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
TypeInfo? originalType = null;
|
||||
|
||||
// convert to doubles.
|
||||
double startValue;
|
||||
try
|
||||
{
|
||||
startValue = CastToDouble(start);
|
||||
originalType = start.WrappedValue.TypeInfo;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
startValue = double.NaN;
|
||||
}
|
||||
|
||||
double endValue;
|
||||
try
|
||||
{
|
||||
endValue = CastToDouble(end);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
endValue = double.NaN;
|
||||
}
|
||||
|
||||
// check for bad bounds.
|
||||
if (double.IsNaN(startValue) || double.IsNaN(endValue))
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
var value = new DataValue
|
||||
{
|
||||
SourceTimestamp = GetTimestamp(slice),
|
||||
ServerTimestamp = GetTimestamp(slice)
|
||||
};
|
||||
|
||||
if (!IsGood(start) || !IsGood(end))
|
||||
{
|
||||
value.StatusCode = StatusCodes.UncertainDataSubNormal;
|
||||
}
|
||||
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
|
||||
|
||||
// calculate delta.
|
||||
double delta = endValue - startValue;
|
||||
|
||||
if (originalType != null && originalType.Value.BuiltInType != BuiltInType.Double)
|
||||
{
|
||||
object delta2 = LegacyCast(
|
||||
delta,
|
||||
TypeInfo.Scalars.Double,
|
||||
originalType.Value.BuiltInType);
|
||||
value.WrappedValue = SdkValueBoundary.FromClr(delta2);
|
||||
}
|
||||
else
|
||||
{
|
||||
value.WrappedValue = SdkValueBoundary.FromClr(delta);
|
||||
}
|
||||
|
||||
// return result.
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
/* ========================================================================
|
||||
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
|
||||
*
|
||||
* OPC Foundation MIT License 1.00
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person
|
||||
* obtaining a copy of this software and associated documentation
|
||||
* files (the "Software"), to deal in the Software without
|
||||
* restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the
|
||||
* Software is furnished to do so, subject to the following
|
||||
* conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be
|
||||
* included in all copies or substantial portions of the Software.
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
* OTHER DEALINGS IN THE SOFTWARE.
|
||||
*
|
||||
* The complete license agreement can be found here:
|
||||
* http://opcfoundation.org/License/MIT/1.00/
|
||||
* ======================================================================*/
|
||||
|
||||
using Opc.Ua;
|
||||
using DataValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.LegacyDataValue;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the value of an aggregate.
|
||||
/// </summary>
|
||||
internal class StatusAggregateCalculator : AggregateCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the aggregate calculator.
|
||||
/// </summary>
|
||||
/// <param name="aggregateId">The aggregate function to apply.</param>
|
||||
/// <param name="startTime">The start time.</param>
|
||||
/// <param name="endTime">The end time.</param>
|
||||
/// <param name="processingInterval">The processing interval.</param>
|
||||
/// <param name="stepped">Whether to use stepped interpolation.</param>
|
||||
/// <param name="configuration">The aggregate configuration.</param>
|
||||
/// <param name="telemetry">The telemetry context to use to create obvservability instruments</param>
|
||||
public StatusAggregateCalculator(
|
||||
NodeId aggregateId,
|
||||
DateTime startTime,
|
||||
DateTime endTime,
|
||||
double processingInterval,
|
||||
bool stepped,
|
||||
AggregateConfiguration configuration,
|
||||
ITelemetryContext? telemetry)
|
||||
: base(aggregateId, startTime, endTime, processingInterval, stepped, configuration, telemetry)
|
||||
{
|
||||
SetPartialBit = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the value for the timeslice.
|
||||
/// </summary>
|
||||
protected override DataValue ComputeValue(TimeSlice slice)
|
||||
{
|
||||
uint? id = AggregateId.TryGetValue(out uint numericId) ? numericId : null;
|
||||
|
||||
if (id == null)
|
||||
{
|
||||
return base.ComputeValue(slice);
|
||||
}
|
||||
switch (id.Value)
|
||||
{
|
||||
case Objects.AggregateFunction_DurationGood:
|
||||
return ComputeDurationGoodBad(slice, false, false);
|
||||
case Objects.AggregateFunction_DurationBad:
|
||||
return ComputeDurationGoodBad(slice, true, false);
|
||||
case Objects.AggregateFunction_PercentGood:
|
||||
return ComputeDurationGoodBad(slice, false, true);
|
||||
case Objects.AggregateFunction_PercentBad:
|
||||
return ComputeDurationGoodBad(slice, true, true);
|
||||
case Objects.AggregateFunction_WorstQuality:
|
||||
return ComputeWorstQuality(slice, false);
|
||||
case Objects.AggregateFunction_WorstQuality2:
|
||||
return ComputeWorstQuality(slice, true);
|
||||
default:
|
||||
return base.ComputeValue(slice);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the DurationGood and DurationBad aggregates for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeDurationGoodBad(TimeSlice slice, bool isBad, bool usePercent)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values = GetValuesWithSimpleBounds(slice);
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null || values.Count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
// get the regions.
|
||||
List<SubRegion> regions = GetRegionsInValueSet(values, false, true);
|
||||
|
||||
double duration = 0;
|
||||
double total = 0;
|
||||
|
||||
for (int ii = 0; ii < regions.Count; ii++)
|
||||
{
|
||||
total += regions[ii].Duration;
|
||||
|
||||
if (isBad)
|
||||
{
|
||||
if (StatusCode.IsBad(regions[ii].StatusCode) ||
|
||||
(Configuration.TreatUncertainAsBad &&
|
||||
StatusCode.IsUncertain(regions[ii].StatusCode)))
|
||||
{
|
||||
duration += regions[ii].Duration;
|
||||
}
|
||||
}
|
||||
else if (StatusCode.IsGood(regions[ii].StatusCode) ||
|
||||
(!Configuration.TreatUncertainAsBad &&
|
||||
StatusCode.IsUncertain(regions[ii].StatusCode)))
|
||||
{
|
||||
duration += regions[ii].Duration;
|
||||
}
|
||||
}
|
||||
|
||||
if (usePercent)
|
||||
{
|
||||
duration = duration / total * 100;
|
||||
}
|
||||
|
||||
// set the timestamp and status.
|
||||
var value = new DataValue
|
||||
{
|
||||
WrappedValue = SdkValueBoundary.FromClr(duration),
|
||||
SourceTimestamp = GetTimestamp(slice),
|
||||
ServerTimestamp = GetTimestamp(slice)
|
||||
};
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
|
||||
|
||||
// return result.
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the DurationGood and DurationBad aggregates for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeWorstQuality(TimeSlice slice, bool includeBounds)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values = GetValues(slice);
|
||||
|
||||
if (includeBounds && values != null)
|
||||
{
|
||||
DateTime startTime = GetTimestamp(slice);
|
||||
DataValue? startBound = GetSimpleBound(startTime, slice);
|
||||
int startIndex = TimeFlowsBackward ? values.Count - 1 : 0;
|
||||
|
||||
if (startBound != null &&
|
||||
(values.Count == 0 || values[startIndex].SourceTimestamp != startTime))
|
||||
{
|
||||
if (TimeFlowsBackward)
|
||||
{
|
||||
values.Add(startBound);
|
||||
}
|
||||
else
|
||||
{
|
||||
values.Insert(0, startBound);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null || values.Count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
// get the regions.
|
||||
_ = GetRegionsInValueSet(values, false, true);
|
||||
|
||||
int firstIndex = TimeFlowsBackward ? values.Count - 1 : 0;
|
||||
StatusCode worstQuality = values[firstIndex].StatusCode.CodeBits;
|
||||
int badQualityCount = 0;
|
||||
int uncertainQualityCount = 0;
|
||||
int goodQualityCount = 0;
|
||||
|
||||
for (int index = 0; index < values.Count; index++)
|
||||
{
|
||||
int ii = TimeFlowsBackward ? values.Count - 1 - index : index;
|
||||
StatusCode quality = values[ii].StatusCode;
|
||||
|
||||
if (StatusCode.IsBad(quality))
|
||||
{
|
||||
badQualityCount++;
|
||||
|
||||
if (StatusCode.IsNotBad(worstQuality))
|
||||
{
|
||||
worstQuality = quality.CodeBits;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (StatusCode.IsUncertain(quality))
|
||||
{
|
||||
uncertainQualityCount++;
|
||||
|
||||
if (StatusCode.IsGood(worstQuality))
|
||||
{
|
||||
worstQuality = quality.CodeBits;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
goodQualityCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// set the timestamp and status.
|
||||
var value = new DataValue
|
||||
{
|
||||
WrappedValue = SdkValueBoundary.FromClr(worstQuality),
|
||||
SourceTimestamp = GetTimestamp(slice),
|
||||
ServerTimestamp = GetTimestamp(slice)
|
||||
};
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
|
||||
|
||||
if ((StatusCode.IsBad(worstQuality) && badQualityCount > 1) ||
|
||||
(StatusCode.IsUncertain(worstQuality) && uncertainQualityCount > 1) ||
|
||||
(StatusCode.IsGood(worstQuality) && goodQualityCount > 1))
|
||||
{
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(
|
||||
value.StatusCode.AggregateBits | AggregateBits.MultipleValues);
|
||||
}
|
||||
|
||||
// return result.
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
/* ========================================================================
|
||||
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
|
||||
*
|
||||
* OPC Foundation MIT License 1.00
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person
|
||||
* obtaining a copy of this software and associated documentation
|
||||
* files (the "Software"), to deal in the Software without
|
||||
* restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the
|
||||
* Software is furnished to do so, subject to the following
|
||||
* conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be
|
||||
* included in all copies or substantial portions of the Software.
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
* OTHER DEALINGS IN THE SOFTWARE.
|
||||
*
|
||||
* The complete license agreement can be found here:
|
||||
* http://opcfoundation.org/License/MIT/1.00/
|
||||
* ======================================================================*/
|
||||
|
||||
using Opc.Ua;
|
||||
using DataValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.LegacyDataValue;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the value of an aggregate.
|
||||
/// </summary>
|
||||
internal class StdDevAggregateCalculator : AggregateCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the aggregate calculator.
|
||||
/// </summary>
|
||||
/// <param name="aggregateId">The aggregate function to apply.</param>
|
||||
/// <param name="startTime">The start time.</param>
|
||||
/// <param name="endTime">The end time.</param>
|
||||
/// <param name="processingInterval">The processing interval.</param>
|
||||
/// <param name="stepped">Whether to use stepped interpolation.</param>
|
||||
/// <param name="configuration">The aggregate configuration.</param>
|
||||
/// <param name="telemetry">The telemetry context to use to create obvservability instruments</param>
|
||||
public StdDevAggregateCalculator(
|
||||
NodeId aggregateId,
|
||||
DateTime startTime,
|
||||
DateTime endTime,
|
||||
double processingInterval,
|
||||
bool stepped,
|
||||
AggregateConfiguration configuration,
|
||||
ITelemetryContext? telemetry)
|
||||
: base(aggregateId, startTime, endTime, processingInterval, stepped, configuration, telemetry)
|
||||
{
|
||||
SetPartialBit = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the value for the timeslice.
|
||||
/// </summary>
|
||||
protected override DataValue ComputeValue(TimeSlice slice)
|
||||
{
|
||||
uint? id = AggregateId.TryGetValue(out uint numericId) ? numericId : null;
|
||||
|
||||
if (id == null)
|
||||
{
|
||||
return base.ComputeValue(slice);
|
||||
}
|
||||
switch (id.Value)
|
||||
{
|
||||
// valueType == 1: StandardDeviation, valueType == 2: Variance
|
||||
|
||||
// includeBounds == true: sample, includeBounds == false: population
|
||||
// (this is a strange way to distinguish between sample and population)
|
||||
|
||||
case Objects.AggregateFunction_StandardDeviationPopulation:
|
||||
return ComputeStdDev(slice, false, 1);
|
||||
case Objects.AggregateFunction_StandardDeviationSample:
|
||||
return ComputeStdDev(slice, true, 1);
|
||||
case Objects.AggregateFunction_VariancePopulation:
|
||||
return ComputeStdDev(slice, false, 2);
|
||||
case Objects.AggregateFunction_VarianceSample:
|
||||
return ComputeStdDev(slice, true, 2);
|
||||
default:
|
||||
return base.ComputeValue(slice);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the RegSlope, RegConst and RegStdDev aggregates for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeRegression(TimeSlice slice, int valueType)
|
||||
{
|
||||
// get the values in the slice.
|
||||
List<DataValue>? values = GetValuesWithSimpleBounds(slice);
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null || values.Count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
// get the regions.
|
||||
List<SubRegion> regions = GetRegionsInValueSet(values, false, true);
|
||||
|
||||
var xData = new List<double>();
|
||||
var yData = new List<double>();
|
||||
|
||||
double duration = 0;
|
||||
bool nonGoodDataExists = false;
|
||||
|
||||
for (int ii = 0; ii < regions.Count; ii++)
|
||||
{
|
||||
if (StatusCode.IsGood(regions[ii].StatusCode))
|
||||
{
|
||||
xData.Add(regions[ii].StartValue);
|
||||
yData.Add(duration);
|
||||
}
|
||||
else
|
||||
{
|
||||
nonGoodDataExists = true;
|
||||
}
|
||||
|
||||
// normalize to seconds.
|
||||
duration += regions[ii].Duration / 1000.0;
|
||||
}
|
||||
|
||||
// check if no good data.
|
||||
if (xData.Count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
// compute the regression parameters.
|
||||
double regSlope = 0;
|
||||
double regConst = 0;
|
||||
double regStdDev = 0;
|
||||
|
||||
if (xData.Count > 1)
|
||||
{
|
||||
double xAvg = 0;
|
||||
double yAvg = 0;
|
||||
double xxAgv = 0;
|
||||
double xyAvg = 0;
|
||||
|
||||
for (int ii = 0; ii < xData.Count; ii++)
|
||||
{
|
||||
xAvg += xData[ii];
|
||||
yAvg += yData[ii];
|
||||
xxAgv += xData[ii] * xData[ii];
|
||||
xyAvg += xData[ii] * yData[ii];
|
||||
}
|
||||
|
||||
xAvg /= xData.Count;
|
||||
yAvg /= xData.Count;
|
||||
xxAgv /= xData.Count;
|
||||
xyAvg /= xData.Count;
|
||||
|
||||
regSlope = (xyAvg - (xAvg * yAvg)) / (xxAgv - (xAvg * xAvg));
|
||||
regConst = yAvg - (regSlope * xAvg);
|
||||
|
||||
var errors = new List<double>();
|
||||
|
||||
double eAvg = 0;
|
||||
|
||||
for (int ii = 0; ii < xData.Count; ii++)
|
||||
{
|
||||
double error = yData[ii] - regConst - (regSlope * xData[ii]);
|
||||
errors.Add(error);
|
||||
eAvg += error;
|
||||
}
|
||||
|
||||
eAvg /= errors.Count;
|
||||
|
||||
double variance = 0;
|
||||
|
||||
for (int ii = 0; ii < errors.Count; ii++)
|
||||
{
|
||||
double error = errors[ii] - eAvg;
|
||||
variance += error * error;
|
||||
}
|
||||
|
||||
variance /= errors.Count;
|
||||
regStdDev = Math.Sqrt(variance);
|
||||
}
|
||||
|
||||
// select the result.
|
||||
double result = 0;
|
||||
|
||||
switch (valueType)
|
||||
{
|
||||
case 1:
|
||||
result = regSlope;
|
||||
break;
|
||||
case 2:
|
||||
result = regConst;
|
||||
break;
|
||||
case 3:
|
||||
result = regStdDev;
|
||||
break;
|
||||
default:
|
||||
Debug.Fail($"Unexpected value type {valueType}");
|
||||
break;
|
||||
}
|
||||
|
||||
// set the timestamp and status.
|
||||
var value = new DataValue
|
||||
{
|
||||
WrappedValue = SdkValueBoundary.FromClr(result),
|
||||
SourceTimestamp = GetTimestamp(slice),
|
||||
ServerTimestamp = GetTimestamp(slice)
|
||||
};
|
||||
|
||||
if (nonGoodDataExists)
|
||||
{
|
||||
value.StatusCode = StatusCodes.UncertainDataSubNormal;
|
||||
}
|
||||
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
|
||||
|
||||
// return result.
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates sample or population standard deviation/variance for the timeslice.
|
||||
/// </summary>
|
||||
protected DataValue ComputeStdDev(TimeSlice slice, bool includeBounds, int valueType)
|
||||
{
|
||||
// Part 13 defines these over good raw values in the interval, without bounds.
|
||||
List<DataValue>? values = GetValues(slice);
|
||||
|
||||
// check for empty slice.
|
||||
if (values == null || values.Count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
var xData = new List<double>();
|
||||
double sum = 0;
|
||||
bool nonGoodDataExists = false;
|
||||
|
||||
for (int ii = 0; ii < values.Count; ii++)
|
||||
{
|
||||
if (IsGood(values[ii]))
|
||||
{
|
||||
try
|
||||
{
|
||||
double sample = CastToDouble(values[ii]);
|
||||
xData.Add(sample);
|
||||
sum += sample;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
nonGoodDataExists = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
nonGoodDataExists = true;
|
||||
}
|
||||
}
|
||||
|
||||
// check if no good data.
|
||||
if (xData.Count == 0)
|
||||
{
|
||||
return GetNoDataValue(slice);
|
||||
}
|
||||
|
||||
double average = sum / xData.Count;
|
||||
|
||||
// calculate variance.
|
||||
double variance = 0;
|
||||
|
||||
for (int ii = 0; ii < xData.Count; ii++)
|
||||
{
|
||||
double error = xData[ii] - average;
|
||||
variance += error * error;
|
||||
}
|
||||
|
||||
if (includeBounds)
|
||||
{
|
||||
// Spec part 13 v105 section 5.4.3.37 and subsequent
|
||||
if (xData.Count <= 1)
|
||||
{
|
||||
variance = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
variance /= xData.Count - 1;
|
||||
}
|
||||
}
|
||||
// use the population variance if bounds are not included.
|
||||
else
|
||||
{
|
||||
variance /= xData.Count;
|
||||
}
|
||||
|
||||
// select the result.
|
||||
double result = 0;
|
||||
|
||||
switch (valueType)
|
||||
{
|
||||
case 1:
|
||||
result = Math.Sqrt(variance);
|
||||
break;
|
||||
case 2:
|
||||
result = variance;
|
||||
break;
|
||||
default:
|
||||
Debug.Fail($"Unexpected value type {valueType}");
|
||||
break;
|
||||
}
|
||||
|
||||
// set the timestamp and status.
|
||||
var value = new DataValue
|
||||
{
|
||||
WrappedValue = SdkValueBoundary.FromClr(result),
|
||||
SourceTimestamp = GetTimestamp(slice),
|
||||
ServerTimestamp = GetTimestamp(slice)
|
||||
};
|
||||
|
||||
if (nonGoodDataExists)
|
||||
{
|
||||
value.StatusCode = StatusCodes.UncertainDataSubNormal;
|
||||
}
|
||||
|
||||
value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
|
||||
|
||||
// return result.
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
[
|
||||
{
|
||||
"file": "AggregateCalculator.cs",
|
||||
"source": "https://raw.githubusercontent.com/OPCFoundation/UA-.NETStandard/1.5.378.182/Libraries/Opc.Ua.Server/Aggregates/AggregateCalculator.cs",
|
||||
"sha256": "913a98e96fe1daeb592d754f42729fe73e6ea696f8094b56089bad1ad4853905"
|
||||
},
|
||||
{
|
||||
"file": "AverageAggregateCalculator.cs",
|
||||
"source": "https://raw.githubusercontent.com/OPCFoundation/UA-.NETStandard/1.5.378.182/Libraries/Opc.Ua.Server/Aggregates/AverageAggregateCalculator.cs",
|
||||
"sha256": "6292ca7d277de2588046a789d1cd74aea3cb8c4c8ca698ff2d6a4e32e2a58ab0"
|
||||
},
|
||||
{
|
||||
"file": "MinMaxAggregateCalculator.cs",
|
||||
"source": "https://raw.githubusercontent.com/OPCFoundation/UA-.NETStandard/1.5.378.182/Libraries/Opc.Ua.Server/Aggregates/MinMaxAggregateCalculator.cs",
|
||||
"sha256": "3a9a07aa0e8eb37a905bb670e766531332abad393e3ad57b3985beee6b44549c"
|
||||
},
|
||||
{
|
||||
"file": "CountAggregateCalculator.cs",
|
||||
"source": "https://raw.githubusercontent.com/OPCFoundation/UA-.NETStandard/1.5.378.182/Libraries/Opc.Ua.Server/Aggregates/CountAggregateCalculator.cs",
|
||||
"sha256": "1e98095aaa4fa30918df6e3d63abb261852587cdf2c079d842d8bf2acecb48b8"
|
||||
},
|
||||
{
|
||||
"file": "StartEndAggregateCalculator.cs",
|
||||
"source": "https://raw.githubusercontent.com/OPCFoundation/UA-.NETStandard/1.5.378.182/Libraries/Opc.Ua.Server/Aggregates/StartEndAggregateCalculator.cs",
|
||||
"sha256": "db4d8a69b4d8d5f599f60ee85861252e55cc1779f6ba29a3146ddf4c2958543a"
|
||||
},
|
||||
{
|
||||
"file": "StatusAggregateCalculator.cs",
|
||||
"source": "https://raw.githubusercontent.com/OPCFoundation/UA-.NETStandard/1.5.378.182/Libraries/Opc.Ua.Server/Aggregates/StatusAggregateCalculator.cs",
|
||||
"sha256": "82fa78d6cd3331103ce7f2c03f0545078e355062650ebf1ec07a96446aa5854f"
|
||||
},
|
||||
{
|
||||
"file": "StdDevAggregateCalculator.cs",
|
||||
"source": "https://raw.githubusercontent.com/OPCFoundation/UA-.NETStandard/1.5.378.182/Libraries/Opc.Ua.Server/Aggregates/StdDevAggregateCalculator.cs",
|
||||
"sha256": "c202d66c2cad946554ffac3b26ecd22e59f458894637a8cd98bbc541519996a2"
|
||||
},
|
||||
{
|
||||
"file": "Aggregators.cs",
|
||||
"source": "https://raw.githubusercontent.com/OPCFoundation/UA-.NETStandard/1.5.378.182/Libraries/Opc.Ua.Server/Aggregates/Aggregators.cs",
|
||||
"sha256": "1d47a0a3d8a6a4985cbafc54425fed26ab2a20e22ec964eb83553e6145b71fa0"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,87 @@
|
||||
using Opc.Ua;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
/// <summary>
|
||||
/// Preserves the application's established equipment-write conversions across SDK 2.0's
|
||||
/// new pre-handler type validation. The owning callback remains the authorization and shape gate.
|
||||
/// </summary>
|
||||
internal sealed class EquipmentVariableState(NodeState? parent, object ownerLock) : BaseDataVariableState(parent)
|
||||
{
|
||||
// Every field below is accessed under ownerLock. Tokens identify client attempts; the mutation
|
||||
// revision detects a driver publication that re-enters the callback before its optimistic commit.
|
||||
private long _nextWriteToken;
|
||||
private long _mutationRevision;
|
||||
private long? _activeWriteToken;
|
||||
private long? _committedWriteToken;
|
||||
|
||||
internal readonly record struct WriteStamp(EquipmentVariableState Owner, long Token);
|
||||
internal WriteStamp? ActiveWriteStamp => _activeWriteToken is { } token ? new(this, token) : null;
|
||||
internal bool OwnsWrite(WriteStamp stamp) => ReferenceEquals(this, stamp.Owner) && _committedWriteToken == stamp.Token;
|
||||
|
||||
internal void InvalidateWriteOwnership()
|
||||
{
|
||||
_mutationRevision++;
|
||||
_committedWriteToken = null;
|
||||
}
|
||||
|
||||
internal void PublishValue(Variant value, StatusCode status, DateTimeUtc timestamp)
|
||||
{
|
||||
InvalidateWriteOwnership();
|
||||
Value = value;
|
||||
StatusCode = status;
|
||||
Timestamp = timestamp;
|
||||
ChangeMasks |= NodeStateChangeMasks.Value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ServiceResult WriteValueAttribute(ISystemContext context, NumericRange indexRange,
|
||||
Variant value, StatusCode statusCode, DateTimeUtc sourceTimestamp)
|
||||
{
|
||||
lock (ownerLock)
|
||||
{
|
||||
if (OnWriteValue is not { } write) return base.WriteValueAttribute(context, indexRange, value, statusCode, sourceTimestamp);
|
||||
|
||||
if ((AccessLevel & AccessLevels.CurrentWrite) == 0) return StatusCodes.BadNotWritable;
|
||||
var userAccess = UserAccessLevel;
|
||||
OnReadUserAccessLevel?.Invoke(context, this, ref userAccess);
|
||||
if ((userAccess & AccessLevels.CurrentWrite) == 0) return StatusCodes.BadUserAccessDenied;
|
||||
|
||||
// Match BaseVariableState.CheckStatusAndTimestampWritable in exact tagged SDK 2.0.1.
|
||||
if (!statusCode.Equals(StatusCodes.Good, StatusCodeComparison.CodeBitsOnly))
|
||||
{
|
||||
if ((AccessLevel & AccessLevels.StatusWrite) == 0) return StatusCodes.BadWriteNotSupported;
|
||||
if ((userAccess & AccessLevels.StatusWrite) == 0) return StatusCodes.BadUserAccessDenied;
|
||||
}
|
||||
if (sourceTimestamp != DateTimeUtc.MinValue)
|
||||
{
|
||||
if ((AccessLevel & AccessLevels.TimestampWrite) == 0) return StatusCodes.BadWriteNotSupported;
|
||||
if ((userAccess & AccessLevels.TimestampWrite) == 0) return StatusCodes.BadUserAccessDenied;
|
||||
}
|
||||
|
||||
var mutationBefore = _mutationRevision;
|
||||
var token = ++_nextWriteToken;
|
||||
var previousAttempt = _activeWriteToken;
|
||||
_activeWriteToken = token;
|
||||
ServiceResult result;
|
||||
try { result = write(context, this, indexRange, default, ref value, ref statusCode, ref sourceTimestamp); }
|
||||
finally { _activeWriteToken = previousAttempt; }
|
||||
if (ServiceResult.IsBad(result)) return result;
|
||||
|
||||
// A reentrant driver update is newer than this attempt. Do not overwrite its tuple.
|
||||
if (_mutationRevision != mutationBefore) return result;
|
||||
// Value's first-touch setter can reset status. Commit in this order, under the shared lock.
|
||||
PublishValue(value, statusCode, sourceTimestamp == DateTimeUtc.MinValue ? DateTimeUtc.Now : sourceTimestamp);
|
||||
_committedWriteToken = token;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ServiceResult ReadValueAttribute(ISystemContext context, NumericRange indexRange,
|
||||
QualifiedName dataEncoding, ref Variant value, ref DateTimeUtc sourceTimestamp)
|
||||
{
|
||||
// Subscription samplers read the node directly, bypassing the manager's service lock.
|
||||
lock (ownerLock) return base.ReadValueAttribute(context, indexRange, dataEncoding, ref value, ref sourceTimestamp);
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,7 @@ internal static class HistoryBounds
|
||||
/// <returns>The placeholder.</returns>
|
||||
public static DataValueSnapshot NotFound(DateTime atUtc)
|
||||
{
|
||||
var placeholder = new DataValueSnapshot(null, StatusCodes.BadBoundNotFound, atUtc, atUtc);
|
||||
var placeholder = new DataValueSnapshot(null, StatusCodes.BadBoundNotFound.Code, atUtc, atUtc);
|
||||
Placeholders.AddOrUpdate(placeholder, PlaceholderMark);
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
@@ -43,8 +44,8 @@ internal interface IHistoryContinuationStore
|
||||
|
||||
/// <summary>
|
||||
/// Production <see cref="IHistoryContinuationStore"/> backed by the OPC UA SDK's per-session
|
||||
/// history-continuation store (<see cref="ISession.SaveHistoryContinuationPoint"/> /
|
||||
/// <see cref="ISession.RestoreHistoryContinuationPoint"/>). Using the SDK store gives us, for free:
|
||||
/// history-continuation store (<see cref="ISessionContinuationPoints.SaveHistory"/> /
|
||||
/// <see cref="ISessionContinuationPoints.RestoreHistory"/>). Using the SDK store gives us, for free:
|
||||
/// <list type="bullet">
|
||||
/// <item>per-session lifecycle — points are disposed when the session closes, so a client that
|
||||
/// disconnects mid-page can never leak resume state;</item>
|
||||
@@ -75,7 +76,7 @@ internal sealed class SessionHistoryContinuationStore : IHistoryContinuationStor
|
||||
// A fresh Guid is the opaque point: 16 bytes, collision-free, and the SDK keys its session slot by
|
||||
// it. The SDK enforces ServerConfiguration.MaxHistoryContinuationPoints with oldest-eviction.
|
||||
var id = Guid.NewGuid();
|
||||
session.SaveHistoryContinuationPoint(id, state);
|
||||
session.ContinuationPoints.SaveHistory(new SessionHistoryPoint(id, state));
|
||||
return id.ToByteArray();
|
||||
}
|
||||
|
||||
@@ -85,13 +86,22 @@ internal sealed class SessionHistoryContinuationStore : IHistoryContinuationStor
|
||||
if (session is null) return null;
|
||||
// RestoreHistoryContinuationPoint REMOVES the slot (single-use) and returns null for an unknown /
|
||||
// malformed (non-16-byte) point — exactly the "miss ⇒ BadContinuationPointInvalid" contract.
|
||||
return session.RestoreHistoryContinuationPoint(continuationPoint) as HistoryContinuationState;
|
||||
using var point = session.ContinuationPoints.RestoreHistory(ByteString.From(continuationPoint));
|
||||
return (point as SessionHistoryPoint)?.State;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Release(ISession? session, byte[] continuationPoint) =>
|
||||
// Restoring removes the slot; we discard the value. Null session / unknown point ⇒ no-op.
|
||||
session?.RestoreHistoryContinuationPoint(continuationPoint);
|
||||
session?.ContinuationPoints.ReleaseHistory(ByteString.From(continuationPoint));
|
||||
private sealed class SessionHistoryPoint(Guid id, HistoryContinuationState state) : IHistoryContinuationPoint
|
||||
{
|
||||
public Guid Id { get; } = id;
|
||||
public HistoryContinuationState State { get; } = state;
|
||||
// The immutable resume state contains managed value data only. No handles or owned resources.
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -73,6 +73,8 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
/// <paramref name="NextStartUtc"/> that still has un-emitted ties — the next page over-fetches the cluster directly
|
||||
/// instead of first re-reading <c>N + skip + 1</c> rows that are known to be ties.
|
||||
/// </param>
|
||||
/// <param name="BoundNodeKey">The exact namespace-qualified node identity; required on every production cursor.</param>
|
||||
/// <param name="AddressSpaceEpoch">The topology generation. A removal conservatively invalidates all cursors.</param>
|
||||
internal sealed record HistoryContinuationState(
|
||||
string Tagname,
|
||||
DateTime NextStartUtc,
|
||||
@@ -87,7 +89,9 @@ internal sealed record HistoryContinuationState(
|
||||
long EmptyFromTicks = 0,
|
||||
long EmptyToTicks = 0,
|
||||
HistoryEndBound? PendingEndBound = null,
|
||||
bool WithinTieCluster = false)
|
||||
bool WithinTieCluster = false,
|
||||
string? BoundNodeKey = null,
|
||||
long AddressSpaceEpoch = -1)
|
||||
{
|
||||
/// <summary>The walk-back hint this cursor carries.</summary>
|
||||
public ReverseWalkHint WalkHint => new(SegmentWidthTicks, EmptyFromTicks, EmptyToTicks);
|
||||
@@ -143,9 +147,8 @@ internal static class HistoryPaging
|
||||
if (server?.DiagnosticsNodeManager is not DiagnosticsNodeManager diagnostics) return false;
|
||||
try
|
||||
{
|
||||
lock (diagnostics.Lock)
|
||||
{
|
||||
var capabilities = diagnostics.GetDefaultHistoryCapabilities();
|
||||
var capabilities = diagnostics.GetDefaultHistoryCapabilitiesAsync().AsTask().GetAwaiter().GetResult();
|
||||
if (capabilities?.MaxReturnDataValues is not { } property) return false;
|
||||
property.Value = (uint)Math.Max(0, maxRawValuesPerRead);
|
||||
property.ClearChangeMasks(diagnostics.SystemContext, false);
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Configuration;
|
||||
using Opc.Ua.Identity;
|
||||
using Opc.Ua.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
@@ -172,7 +173,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
private readonly ILogger<OpcUaApplicationHost> _logger;
|
||||
private ApplicationInstance? _application;
|
||||
private StandardServer? _server;
|
||||
private ImpersonateEventHandler? _impersonateHandler;
|
||||
private readonly ITelemetryContext _telemetry = DefaultTelemetry.Create(_ => { });
|
||||
private readonly List<IUserTokenAuthenticator> _identityAuthenticators = [];
|
||||
|
||||
/// <summary>Initializes a new instance of the OPC UA application host.</summary>
|
||||
/// <param name="options">The host configuration options.</param>
|
||||
@@ -200,52 +202,67 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task StartAsync(StandardServer server, CancellationToken cancellationToken)
|
||||
{
|
||||
if (server is not OtOpcUaSdkServer sdkServer)
|
||||
throw new ArgumentException("The server must support application authentication before its listeners open.", nameof(server));
|
||||
_server = server;
|
||||
// 1.5.378 requires an ITelemetryContext on the ApplicationInstance ctor (the parameterless ctor
|
||||
// is obsolete). DefaultTelemetry.Create wires the SDK's internal logging; an empty builder keeps
|
||||
// the SDK's trace off our ILogger (the host keeps its own _logger) — sufficient for the bootstrap.
|
||||
_application = new ApplicationInstance(DefaultTelemetry.Create(_ => { }))
|
||||
_application = new ApplicationInstance(_telemetry)
|
||||
{
|
||||
ApplicationName = _options.ApplicationName,
|
||||
ApplicationType = ApplicationType.Server,
|
||||
ConfigSectionName = "OtOpcUa",
|
||||
};
|
||||
|
||||
_ = await BuildConfigurationAsync(cancellationToken);
|
||||
await EnsureApplicationCertificateAsync(cancellationToken).ConfigureAwait(false);
|
||||
await _application.StartAsync(server).ConfigureAwait(false);
|
||||
|
||||
AttachUserAuthenticator();
|
||||
PopulateServerArray();
|
||||
try
|
||||
{
|
||||
_ = await BuildConfigurationAsync(cancellationToken);
|
||||
await EnsureApplicationCertificateAsync(cancellationToken).ConfigureAwait(false);
|
||||
AttachUserAuthenticator(sdkServer);
|
||||
await _application.StartAsync(server, cancellationToken).ConfigureAwait(false);
|
||||
PopulateServerArray();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
_logger.LogInformation("OPC UA server started on opc.tcp://{Host}:{Port}",
|
||||
_options.PublicHostname, _options.OpcUaPort);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to <see cref="SessionManager.ImpersonateUser"/> after the SDK has its
|
||||
/// <c>SessionManager</c> ready (only after <c>_application.Start</c>). Anonymous tokens
|
||||
/// pass through; UserName tokens hit <see cref="IOpcUaUserAuthenticator"/> and, on
|
||||
/// success, attach a <see cref="UserIdentity"/> with the mapped role-set to the session
|
||||
/// so downstream ACL checks can read it via <c>OperationContext.UserIdentity</c>.
|
||||
///
|
||||
/// The SDK calls <c>ImpersonateUser</c> synchronously off the session-activation
|
||||
/// thread, so the authenticator's async work is run via <c>GetAwaiter().GetResult()</c>.
|
||||
/// LDAP binds typically complete in <100 ms; if a backing store ever gets that slow
|
||||
/// it should not block the OPC UA stack — callers must enforce their own timeouts inside
|
||||
/// <see cref="IOpcUaUserAuthenticator.AuthenticateUserNameAsync"/>.
|
||||
/// </summary>
|
||||
private void AttachUserAuthenticator()
|
||||
private void AttachUserAuthenticator(OtOpcUaSdkServer server)
|
||||
{
|
||||
var sessionManager = _server?.CurrentInstance?.SessionManager;
|
||||
if (sessionManager is null)
|
||||
foreach (var type in new[] { UserTokenType.Anonymous, UserTokenType.UserName })
|
||||
{
|
||||
_logger.LogWarning("OpcUaApplicationHost: SessionManager unavailable after Start; UserName auth disabled");
|
||||
return;
|
||||
var adapter = new ApplicationTokenAuthenticator(this, type);
|
||||
_identityAuthenticators.Add(adapter);
|
||||
}
|
||||
server.ConfigureIdentityAuthenticators(_identityAuthenticators);
|
||||
}
|
||||
|
||||
_impersonateHandler = OnImpersonateUser;
|
||||
sessionManager.ImpersonateUser += _impersonateHandler;
|
||||
private sealed class ApplicationTokenAuthenticator(OpcUaApplicationHost host, UserTokenType tokenType)
|
||||
: IUserTokenAuthenticator
|
||||
{
|
||||
public UserTokenType TokenType => tokenType;
|
||||
public string? IssuedTokenProfileUri => null;
|
||||
|
||||
public async ValueTask<AuthenticationResult> AuthenticateAsync(AuthenticationContext context, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var args = new ImpersonateEventArgs(context.TokenHandler, context.UserTokenPolicy, context.EndpointDescription);
|
||||
var peer = (host._server as OtOpcUaSdkServer)?.ActivationPeerAddress;
|
||||
// Port zero is only an adapter for the existing IP-budget seam: the SDK exposes no peer port.
|
||||
await HandleImpersonationAsync(host._userAuthenticator, args, host._logger, host._options.AllowAnonymous,
|
||||
peer is null ? null : new System.Net.IPEndPoint(peer, 0), ct).ConfigureAwait(false);
|
||||
return ServiceResult.IsBad(args.IdentityValidationError)
|
||||
? AuthenticationResult.Reject(args.IdentityValidationError)
|
||||
: args.Identity is { } identity
|
||||
? AuthenticationResult.Accept(identity)
|
||||
: AuthenticationResult.Reject(new ServiceResult(StatusCodes.BadIdentityTokenRejected));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -298,46 +315,18 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
if (!string.IsNullOrWhiteSpace(peer) && !uris.Contains(peer))
|
||||
uris.Add(peer);
|
||||
}
|
||||
serverObject.ServerArray.Value = uris.ToArray();
|
||||
serverObject.ServerArray!.Value = (ArrayOf<string>)uris;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnImpersonateUser(ISession session, ImpersonateEventArgs args) =>
|
||||
HandleImpersonation(_userAuthenticator, args, _logger, _options.AllowAnonymous,
|
||||
// Only a UserName activation consults the endpoint (the per-client API-key miss budget, #751), so
|
||||
// the reflection walk is skipped for anonymous / X509.
|
||||
args.NewIdentity is UserNameIdentityToken ? ResolveRemoteEndpoint(session) : null);
|
||||
|
||||
/// <summary>The remote endpoint of the client activating <paramref name="session"/>, or null when unknown.</summary>
|
||||
/// <param name="session">The session being activated.</param>
|
||||
/// <returns>The client's IP endpoint, or null.</returns>
|
||||
/// <remarks>
|
||||
/// Read from the session's secure channel — the SDK raises impersonation without the request's channel
|
||||
/// context (<c>SecureChannelContext.Current</c> is obsolete in 1.5.378). A client that re-activates over
|
||||
/// a NEW channel after its old one closed resolves to null and is accounted process-wide only.
|
||||
/// </remarks>
|
||||
private System.Net.IPEndPoint? ResolveRemoteEndpoint(ISession? session) =>
|
||||
_server is OtOpcUaSdkServer sdk ? sdk.TryResolveRemoteEndpoint(session?.SecureChannelId) : null;
|
||||
|
||||
/// <summary>
|
||||
/// Pure(-ish) impersonation handler: extracted so unit tests can drive it without booting
|
||||
/// the full SDK. Side-effects are confined to mutating <see cref="ImpersonateEventArgs"/>
|
||||
/// and logging.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>SDK threading contract (verified against OPCFoundation.NetStandard.Opc.Ua.Server 1.5.378).</b>
|
||||
/// The SDK raises this handler SYNCHRONOUSLY — it is a plain <c>void</c> delegate, and there is no
|
||||
/// async impersonation callback in this SDK version. Worse, <c>SessionManager.ActivateSessionAsync</c>
|
||||
/// invokes it while holding a single <c>SessionManager</c>-wide event lock (<c>m_eventLock</c>), so a
|
||||
/// handler that blocks does not merely stall one SDK request thread — it SERIALIZES every session
|
||||
/// activation server-wide (Anonymous and X509 included, since even those paths must take the lock to
|
||||
/// raise the handler). Consequently the <see cref="IOpcUaUserAuthenticator"/> block-bridge below
|
||||
/// (<c>GetAwaiter().GetResult()</c> under <see cref="CancellationToken.None"/>) is irreducible: the
|
||||
/// callback must complete inline. Because the SDK gives no timeout, the hard wall-clock bound + bounded
|
||||
/// concurrency + directory-outage circuit that keep a slow/hung directory from wedging the SDK live
|
||||
/// inside the authenticator — NOT here. (The LDAP authenticator was replaced with
|
||||
/// <c>ApiKeyOpcUaUserAuthenticator</c>, whose verification does no network I/O at all, so the
|
||||
/// bound/concurrency/circuit machinery that used to live there is gone rather than relocated.)
|
||||
/// The registry path awaits authentication outside the SDK event lock. This synchronous observation
|
||||
/// seam remains for existing handler tests; production invokes the asynchronous implementation directly.
|
||||
/// </remarks>
|
||||
/// <param name="authenticator">The user authenticator to validate credentials.</param>
|
||||
/// <param name="args">The impersonation event arguments to process.</param>
|
||||
@@ -351,14 +340,21 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
ILogger logger,
|
||||
bool allowAnonymous = true,
|
||||
System.Net.IPEndPoint? remoteEndpoint = null)
|
||||
=> HandleImpersonationAsync(authenticator, args, logger, allowAnonymous, remoteEndpoint)
|
||||
.AsTask().GetAwaiter().GetResult();
|
||||
|
||||
private static async ValueTask HandleImpersonationAsync(
|
||||
IOpcUaUserAuthenticator authenticator, ImpersonateEventArgs args, ILogger logger,
|
||||
bool allowAnonymous = true, System.Net.IPEndPoint? remoteEndpoint = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (args.NewIdentity is AnonymousIdentityToken anonymousToken)
|
||||
if (args.UserIdentityTokenHandler.Token is AnonymousIdentityToken anonymousToken)
|
||||
{
|
||||
HandleAnonymousImpersonation(authenticator, args, logger, allowAnonymous, anonymousToken);
|
||||
await HandleAnonymousImpersonationAsync(authenticator, args, logger, allowAnonymous, anonymousToken, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.NewIdentity is not UserNameIdentityToken token)
|
||||
if (args.UserIdentityTokenHandler.Token is not UserNameIdentityToken token)
|
||||
{
|
||||
// X509 tokens — let the SDK's default validation stand.
|
||||
return;
|
||||
@@ -369,29 +365,27 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
{
|
||||
// 1.5.378 exposes DecryptedPassword as raw bytes (was string); UserName token passwords
|
||||
// are UTF-8 on the wire.
|
||||
var decryptedBytes = token.DecryptedPassword;
|
||||
var decryptedBytes = (args.UserIdentityTokenHandler as UserNameIdentityTokenHandler)?.DecryptedPassword;
|
||||
password = decryptedBytes is null ? string.Empty : Encoding.UTF8.GetString(decryptedBytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "OpcUaApplicationHost: failed to decrypt UserName token");
|
||||
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
|
||||
"UserName token decryption failed");
|
||||
args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, "UserName token decryption failed");
|
||||
return;
|
||||
}
|
||||
|
||||
OpcUaUserAuthResult result;
|
||||
try
|
||||
{
|
||||
result = authenticator
|
||||
.AuthenticateUserNameAsync(token.UserName ?? string.Empty, password, remoteEndpoint, CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
result = await authenticator
|
||||
.AuthenticateUserNameAsync(token.UserName ?? string.Empty, password, remoteEndpoint, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "OpcUaApplicationHost: UserName authenticator threw for {User}", token.UserName);
|
||||
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
|
||||
"Authentication failed");
|
||||
args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, "Authentication failed");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -399,8 +393,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
{
|
||||
logger.LogInformation("OpcUaApplicationHost: UserName auth denied for {User}: {Error}",
|
||||
token.UserName, result.Error);
|
||||
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
|
||||
result.Error ?? "Invalid credentials");
|
||||
args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, result.Error ?? "Invalid credentials");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -421,28 +414,25 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
/// <see cref="RoleCarryingUserIdentity"/> the UserName path uses, so every downstream gate reads
|
||||
/// it identically and there is no anonymous-shaped branch in any of them.
|
||||
/// </remarks>
|
||||
private static void HandleAnonymousImpersonation(
|
||||
private static async ValueTask HandleAnonymousImpersonationAsync(
|
||||
IOpcUaUserAuthenticator authenticator,
|
||||
ImpersonateEventArgs args,
|
||||
ILogger logger,
|
||||
bool allowAnonymous,
|
||||
AnonymousIdentityToken token)
|
||||
AnonymousIdentityToken token, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!allowAnonymous)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"OpcUaApplicationHost: anonymous session rejected (OpcUa:AllowAnonymous is false)");
|
||||
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
|
||||
"Anonymous access is disabled on this server.");
|
||||
args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, "Anonymous access is disabled on this server.");
|
||||
return;
|
||||
}
|
||||
|
||||
OpcUaUserAuthResult result;
|
||||
try
|
||||
{
|
||||
// Same synchronous bridge, and for the same irreducible reason, as the UserName path above.
|
||||
result = authenticator.AuthenticateAnonymousAsync(CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
result = await authenticator.AuthenticateAnonymousAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -450,16 +440,14 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
// to the SDK default identity, because that would silently ADMIT it — the previous
|
||||
// behaviour — at exactly the moment we could not determine what it is allowed to do.
|
||||
logger.LogError(ex, "OpcUaApplicationHost: anonymous authenticator threw");
|
||||
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
|
||||
"Anonymous access is unavailable.");
|
||||
args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, "Anonymous access is unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
logger.LogInformation("OpcUaApplicationHost: anonymous session denied: {Error}", result.Error);
|
||||
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
|
||||
result.Error ?? "Anonymous access is denied.");
|
||||
args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, result.Error ?? "Anonymous access is denied.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -500,20 +488,14 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
|
||||
var serverConfig = new ServerConfiguration
|
||||
{
|
||||
BaseAddresses = { $"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa" },
|
||||
BaseAddresses = [$"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa"],
|
||||
};
|
||||
ApplyRequestQuotas(serverConfig, _options.Quotas);
|
||||
|
||||
foreach (var policy in BuildSecurityPolicies(_options.EnabledSecurityProfiles))
|
||||
{
|
||||
serverConfig.SecurityPolicies.Add(policy);
|
||||
}
|
||||
foreach (var token in BuildUserTokenPolicies(_options.AllowAnonymous))
|
||||
{
|
||||
serverConfig.UserTokenPolicies.Add(token);
|
||||
}
|
||||
serverConfig.SecurityPolicies = BuildSecurityPolicies(_options.EnabledSecurityProfiles).ToArray();
|
||||
serverConfig.UserTokenPolicies = BuildUserTokenPolicies(_options.AllowAnonymous).ToArray();
|
||||
|
||||
var config = new ApplicationConfiguration
|
||||
var config = new ApplicationConfiguration(_telemetry)
|
||||
{
|
||||
ApplicationName = _options.ApplicationName,
|
||||
ApplicationUri = _options.ApplicationUri,
|
||||
@@ -537,8 +519,10 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
ClientConfiguration = new ClientConfiguration(),
|
||||
};
|
||||
|
||||
await config.ValidateAsync(ApplicationType.Server, ct).ConfigureAwait(false);
|
||||
// Validation creates the certificate manager before awaiting certificate loads. Give the
|
||||
// application ownership first so failed/canceled startup disposes that manager too.
|
||||
_application!.ApplicationConfiguration = config;
|
||||
await config.ValidateAsync(ApplicationType.Server, ct).ConfigureAwait(false);
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -659,23 +643,14 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
// Authenticators remain installed until the SDK closes every listener and disposes its registry.
|
||||
if (_application is { } application)
|
||||
{
|
||||
// An explicit SDK stop may already have halted CurrentInstance; still continue disposal.
|
||||
if (_impersonateHandler is not null && _server?.CurrentInstance?.SessionManager is { } sessionManager)
|
||||
sessionManager.ImpersonateUser -= _impersonateHandler;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "OpcUaApplicationHost: detaching ImpersonateUser threw");
|
||||
}
|
||||
_impersonateHandler = null;
|
||||
|
||||
if (_application is not null)
|
||||
{
|
||||
// 1.5.378: ApplicationInstance.Stop() → StopAsync().
|
||||
try { await _application.StopAsync().ConfigureAwait(false); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: Stop threw on dispose"); }
|
||||
_application = null;
|
||||
try { await application.DisposeAsync().ConfigureAwait(false); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: disposal threw"); }
|
||||
}
|
||||
_identityAuthenticators.Clear();
|
||||
_server = null;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using Opc.Ua.Identity;
|
||||
using Opc.Ua.Server.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
@@ -22,17 +24,62 @@ public sealed class OtOpcUaSdkServer : StandardServer
|
||||
/// <summary>Initializes a new instance of the <see cref="OtOpcUaSdkServer"/> class.</summary>
|
||||
/// <param name="nodeManagerLogger">Logger handed to the created <see cref="OtOpcUaNodeManager"/>;
|
||||
/// <c>null</c> leaves it on a no-op logger.</param>
|
||||
public OtOpcUaSdkServer(ILogger<OtOpcUaNodeManager>? nodeManagerLogger = null) => _nodeManagerLogger = nodeManagerLogger;
|
||||
public OtOpcUaSdkServer(ILogger<OtOpcUaNodeManager>? nodeManagerLogger = null)
|
||||
: base(DefaultTelemetry.Create(_ => { })) => _nodeManagerLogger = nodeManagerLogger;
|
||||
|
||||
/// <summary>
|
||||
/// The client's remote IP endpoint for the secure channel <paramref name="secureChannelId"/>, or null when it
|
||||
/// cannot be resolved (lmxopcua#751). Best effort — see <see cref="SecureChannelRemoteEndpoint"/>; the SDK
|
||||
/// exposes the listeners only to a subclass, which is why this lives here.
|
||||
/// </summary>
|
||||
/// <param name="secureChannelId">The global secure channel id a request arrived on.</param>
|
||||
/// <returns>The remote endpoint, or null.</returns>
|
||||
public System.Net.IPEndPoint? TryResolveRemoteEndpoint(string? secureChannelId) =>
|
||||
SecureChannelRemoteEndpoint.Resolve(TransportListeners, secureChannelId);
|
||||
internal void ConfigureIdentityAuthenticators(IEnumerable<IUserTokenAuthenticator> authenticators) =>
|
||||
AddPreStartupTask(new IdentityStartupTask(authenticators.ToArray()));
|
||||
|
||||
private sealed class IdentityStartupTask(IReadOnlyList<IUserTokenAuthenticator> authenticators) : IServerPreStartupTask
|
||||
{
|
||||
public ValueTask OnServerStartingAsync(IServerContext server, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (server is not IServerInternal internalServer)
|
||||
throw new InvalidOperationException("Application identity registry unavailable during server startup.");
|
||||
foreach (var authenticator in authenticators) internalServer.IdentityRegistry.Register(authenticator);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly AsyncLocal<System.Net.IPAddress?> _activationPeer = new();
|
||||
|
||||
/// <summary>The transport-observed peer of the current activation request, when available.</summary>
|
||||
internal System.Net.IPAddress? ActivationPeerAddress => _activationPeer.Value;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask<ActivateSessionResponse> ActivateSessionAsync(
|
||||
SecureChannelContext secureChannelContext,
|
||||
RequestHeader? requestHeader,
|
||||
SignatureData? clientSignature,
|
||||
ArrayOf<SignedSoftwareCertificate> clientSoftwareCertificates,
|
||||
ArrayOf<string> localeIds,
|
||||
ExtensionObject userIdentityToken,
|
||||
SignatureData? userTokenSignature,
|
||||
RequestLifetime requestLifetime)
|
||||
{
|
||||
return await WithActivationPeerAddressAsync(secureChannelContext.PeerAddress,
|
||||
() => base.ActivateSessionAsync(secureChannelContext, requestHeader, clientSignature,
|
||||
clientSoftwareCertificates, localeIds, userIdentityToken, userTokenSignature, requestLifetime))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
internal async ValueTask<ActivateSessionResponse> WithActivationPeerAddressAsync(
|
||||
System.Net.IPAddress? peer, Func<ValueTask<ActivateSessionResponse>> activation)
|
||||
{
|
||||
// AuthenticationContext does not carry the request peer in 2.0.1. Keep the public
|
||||
// transport evidence in this activation's async flow, including reactivation on a new channel.
|
||||
var previous = _activationPeer.Value;
|
||||
_activationPeer.Value = peer;
|
||||
try
|
||||
{
|
||||
return await activation().ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_activationPeer.Value = previous;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The custom node manager once <c>StartAsync</c> has called
|
||||
/// <see cref="CreateMasterNodeManager"/>. Null until the SDK has bootstrapped.</summary>
|
||||
@@ -224,28 +271,47 @@ public sealed class OtOpcUaSdkServer : StandardServer
|
||||
return true;
|
||||
}
|
||||
|
||||
// Keep the SDK's full set of calculator factories (live-data AggregateFilter subscriptions need
|
||||
// them, and ServerCapabilities/AggregateFunctions keeps listing all of them), then trim
|
||||
// HistoryServerCapabilities/AggregateFunctions to the node manager's current historian source. At
|
||||
// this point that is the Null default (no history aggregates); the Host's SetHistorianDataSource
|
||||
// re-syncs to the configured backend's set right after start.
|
||||
// Register the same 37 standard aggregate IDs exactly once, using the versioned application
|
||||
// calculators. Calling base first would create the diagnostic nodes twice: SDK 2.0.1's
|
||||
// RegisterFactoryAsync replaces the factory but unconditionally adds another address-space node.
|
||||
// HistoryServerCapabilities is then trimmed to the currently served historian aggregate set.
|
||||
/// <inheritdoc />
|
||||
protected override AggregateManager CreateAggregateManager(
|
||||
IServerInternal server, ApplicationConfiguration configuration)
|
||||
protected override async ValueTask<AggregateManager> CreateAggregateManagerAsync(
|
||||
IServerInternal server, ApplicationConfiguration configuration, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var manager = base.CreateAggregateManager(server, configuration);
|
||||
// Pin the SDK manager's default AggregateConfiguration (what live AggregateFilter subscriptions compute under
|
||||
// and what the server advertises) to OUR defaults. Opc.Ua.Server 1.5.378.182 flipped the SDK default of
|
||||
// TreatUncertainAsBad to true; history-read aggregates (Sql.Common evaluators) are fixed at false, so the live
|
||||
// and historical paths must agree and stay behaviour-neutral across the SDK hop (lmxopcua#782).
|
||||
var defaults = manager.GetDefaultConfiguration(null) ?? new AggregateConfiguration();
|
||||
defaults.TreatUncertainAsBad = ServerAggregateConfiguration.TreatUncertainAsBad;
|
||||
defaults.PercentDataBad = ServerAggregateConfiguration.PercentDataBad;
|
||||
defaults.PercentDataGood = ServerAggregateConfiguration.PercentDataGood;
|
||||
defaults.UseSlopedExtrapolation = ServerAggregateConfiguration.UseSlopedExtrapolation;
|
||||
manager.SetDefaultConfiguration(defaults);
|
||||
_otOpcUaNodeManager?.SyncAdvertisedAggregates();
|
||||
return manager;
|
||||
var manager = new AggregateManager(server);
|
||||
try
|
||||
{
|
||||
// Pin the SDK manager's default AggregateConfiguration (what live AggregateFilter subscriptions compute under
|
||||
// and what the server advertises) to OUR defaults. Opc.Ua.Server 1.5.378.182 flipped the SDK default of
|
||||
// TreatUncertainAsBad to true; history-read aggregates (Sql.Common evaluators) are fixed at false, so the live
|
||||
// and historical paths must agree and stay behaviour-neutral across the SDK hop (lmxopcua#782).
|
||||
var defaults = manager.GetDefaultConfiguration(NodeId.Null) ?? new AggregateConfiguration();
|
||||
defaults.TreatUncertainAsBad = ServerAggregateConfiguration.TreatUncertainAsBad;
|
||||
defaults.PercentDataBad = ServerAggregateConfiguration.PercentDataBad;
|
||||
defaults.PercentDataGood = ServerAggregateConfiguration.PercentDataGood;
|
||||
defaults.UseSlopedExtrapolation = ServerAggregateConfiguration.UseSlopedExtrapolation;
|
||||
manager.SetDefaultConfiguration(defaults);
|
||||
await Compatibility.Aggregates.CompatibilityAggregateFactory.RegisterAsync(manager, cancellationToken).ConfigureAwait(false);
|
||||
_otOpcUaNodeManager?.SyncAdvertisedAggregates();
|
||||
return manager;
|
||||
}
|
||||
catch
|
||||
{
|
||||
manager.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask OnNodeManagerStartedAsync(
|
||||
IServerInternal server, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await base.OnNodeManagerStartedAsync(server, cancellationToken).ConfigureAwait(false);
|
||||
var auditType = server.DiagnosticsNodeManager?
|
||||
.FindPredefinedNode<BaseObjectTypeState>(ObjectTypeIds.AuditWriteUpdateEventType)
|
||||
?? throw new InvalidOperationException("AuditWriteUpdateEventType is unavailable during server startup.");
|
||||
_otOpcUaNodeManager!.InstallApplicationAuditPermissions(auditType);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
/// Uses <see cref="IServerInternal.ServerObject"/> (a <see cref="ServerObjectState"/>) and
|
||||
/// its <see cref="ServerObjectState.ServiceLevel"/> child variable, which the SDK populates
|
||||
/// automatically during <see cref="DiagnosticsNodeManager"/> initialization. Writes are
|
||||
/// guarded by <see cref="IServerInternal.DiagnosticsLock"/> so concurrent diagnostics scans
|
||||
/// guarded by <see cref="IServerContext.UpdateServerDiagnostics"/> so concurrent diagnostics scans
|
||||
/// from the SDK don't fight with our update.
|
||||
/// </summary>
|
||||
public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher
|
||||
@@ -44,13 +44,13 @@ public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher
|
||||
|
||||
try
|
||||
{
|
||||
lock (_serverInternal.DiagnosticsLock)
|
||||
_serverInternal.UpdateServerDiagnostics(_ =>
|
||||
{
|
||||
node.Value = serviceLevel;
|
||||
node.Timestamp = DateTime.UtcNow;
|
||||
node.StatusCode = StatusCodes.Good;
|
||||
node.ClearChangeMasks(_serverInternal.DefaultSystemContext, includeChildren: false);
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using Opc.Ua;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
/// <summary>Copies supported SDK payloads at the server's CLR value boundary.</summary>
|
||||
internal static class SdkValueBoundary
|
||||
{
|
||||
/// <summary>Build a detached Variant for an existing supported scalar or one-dimensional array.</summary>
|
||||
internal static Variant FromClr(object? value, bool byteArray = false)
|
||||
{
|
||||
if (value is null) return Variant.Null;
|
||||
if (value is Array array)
|
||||
{
|
||||
if (array.Rank != 1) throw new ArgumentException("Only one-dimensional values are supported.", nameof(value));
|
||||
value = array.Clone();
|
||||
}
|
||||
if (value is byte[] bytes) return byteArray
|
||||
? Variant.From((ArrayOf<byte>)bytes)
|
||||
: Variant.From(ByteString.From(bytes));
|
||||
if (VariantHelper.TryCastFrom(value, out var variant)) return variant;
|
||||
throw new ArgumentException("Unsupported OPC UA value type.", nameof(value));
|
||||
}
|
||||
|
||||
/// <summary>Map the UA zero timestamp to the application's existing unspecified-bound sentinel.</summary>
|
||||
internal static DateTime ToClrTimestamp(DateTimeUtc value) => value == DateTimeUtc.MinValue
|
||||
? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc) : value.ToDateTime();
|
||||
|
||||
/// <summary>Restore existing CLR payload types and detach mutable arrays.</summary>
|
||||
internal static object? ToClr(Variant value) => value.AsBoxedObject(Variant.BoxingBehavior.Legacy) switch
|
||||
{
|
||||
ByteString bytes => bytes.IsNull ? null : bytes.ToArray(),
|
||||
DateTimeUtc time => time.ToDateTime(),
|
||||
Uuid id => (Guid)id,
|
||||
DateTimeUtc[] times => times.Select(x => x.ToDateTime()).ToArray(),
|
||||
Uuid[] ids => ids.Select(x => (Guid)x).ToArray(),
|
||||
ByteString[] strings => strings.Select(x => x.IsNull ? null : x.ToArray()).ToArray(),
|
||||
Array array => array.Clone(),
|
||||
var scalar => scalar,
|
||||
};
|
||||
}
|
||||
@@ -297,7 +297,7 @@ public sealed class DataPlaneAuthorizer
|
||||
// The s= identifier, read once: it is both the cache key and the raw material the scope is split
|
||||
// from, and pulling it up here absorbs what used to be TryBuildScope's own guard. A NodeId that
|
||||
// carries no string addresses no ACL scope at all, so it can never be answered from the cache.
|
||||
if (nodeId?.Identifier is not string identifier || identifier.Length == 0)
|
||||
if (!nodeId.TryGetValue(out string identifier) || identifier.Length == 0)
|
||||
{
|
||||
return Deny(operation, nodeId, identity.Principal,
|
||||
"the node id carries no string identifier, so it addresses no ACL scope", cached);
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="UserIdentity"/> that additionally carries the LDAP-resolved roles
|
||||
/// (<see cref="OpcUaUserAuthResult.Roles"/>) for the authenticated session. The SDK stores the
|
||||
/// instance we assign to <c>ImpersonateEventArgs.Identity</c> by reference onto
|
||||
/// <c>Session.Identity</c>/<c>Session.EffectiveIdentity</c> (no wrapping or copying), so a method
|
||||
/// (<see cref="OpcUaUserAuthResult.Roles"/>) for the authenticated session. The SDK may augment identities with mandatory and dynamically mapped roles. This subtype
|
||||
/// preserves the application identity when those roles are added, so a method
|
||||
/// handler can later recover the roles via
|
||||
/// <c>(context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity</c> — the
|
||||
/// seam the inbound alarm ack/shelve <c>AlarmAck</c> gate (T18) reads. This type only carries the
|
||||
/// roles; it makes no auth decision.
|
||||
/// </summary>
|
||||
public sealed class RoleCarryingUserIdentity : UserIdentity
|
||||
public sealed class RoleCarryingUserIdentity : RoleBasedIdentity
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance, building the base identity from the same UserName token the
|
||||
@@ -35,7 +35,14 @@ public sealed class RoleCarryingUserIdentity : UserIdentity
|
||||
/// <param name="apiKeyId">The public id of the key that authenticated, or null for anonymous.</param>
|
||||
public RoleCarryingUserIdentity(
|
||||
UserIdentityToken token, IReadOnlyList<string> roles, string principal, string? apiKeyId)
|
||||
: base(token)
|
||||
: this(new UserIdentity(token), roles, principal, apiKeyId, [], new NamespaceTable())
|
||||
{
|
||||
}
|
||||
|
||||
private RoleCarryingUserIdentity(
|
||||
IUserIdentity identity, IReadOnlyList<string> roles, string principal, string? apiKeyId,
|
||||
IEnumerable<Role> sdkRoles, NamespaceTable namespaces)
|
||||
: base(identity, sdkRoles, namespaces)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(roles);
|
||||
// Freeze to a defensive copy so a caller mutating the source list can never retroactively
|
||||
@@ -46,8 +53,13 @@ public sealed class RoleCarryingUserIdentity : UserIdentity
|
||||
ApiKeyId = apiKeyId;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override RoleBasedIdentity WithAdditionalRoles(IEnumerable<Role> additionalRoles, NamespaceTable namespaces) =>
|
||||
new RoleCarryingUserIdentity(InnerIdentity, Roles, Principal, ApiKeyId,
|
||||
base.Roles.Concat(additionalRoles), namespaces);
|
||||
|
||||
/// <summary>The roles the authenticator granted this session, used by downstream permission gates.</summary>
|
||||
public IReadOnlyList<string> Roles { get; }
|
||||
public new IReadOnlyList<string> Roles { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The ACL principal this session authenticates as — the name <c>NodeAcl</c> rows key on, and what
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
using System.Collections;
|
||||
using System.Net;
|
||||
using System.Reflection;
|
||||
using Opc.Ua;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the client's remote IP endpoint for a secure channel, so session activation can account
|
||||
/// pre-authentication API-key misses per client (lmxopcua#751).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ⚠️ <b>The OPC UA SDK (1.5.378) does not expose the client address on any public surface</b> — not
|
||||
/// on <c>ISession</c>, <c>SecureChannelContext</c>, <c>ImpersonateEventArgs</c> nor the transport
|
||||
/// listener. The TCP listener holds it privately: <c>TcpTransportListener.m_channels</c> (channel id
|
||||
/// → channel) → the channel's non-public <c>Socket</c> → the public
|
||||
/// <c>IMessageSocket.RemoteEndpoint</c>. This class walks that path by reflection.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It is strictly <b>best effort and fail-soft</b>: any missing member (an SDK upgrade renamed it), an
|
||||
/// unknown channel or a closed socket yields <see langword="null"/>, and a caller treats null as
|
||||
/// "address unknown" — the per-endpoint budget is skipped and the process-wide one still applies. It
|
||||
/// never throws. An over-the-wire test (<c>RemoteEndpointResolutionTests</c>) pins that it resolves on
|
||||
/// the SDK version in use, so an upgrade that breaks the path fails a test instead of silently
|
||||
/// degrading to process-wide accounting.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SecureChannelRemoteEndpoint
|
||||
{
|
||||
private const BindingFlags Instance = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
|
||||
|
||||
/// <summary>
|
||||
/// Finds the remote endpoint of the secure channel <paramref name="secureChannelId"/> among
|
||||
/// <paramref name="listeners"/>.
|
||||
/// </summary>
|
||||
/// <param name="listeners">The server's transport listeners.</param>
|
||||
/// <param name="secureChannelId">The global secure channel id (<c>{ListenerId}-{ChannelId}</c>).</param>
|
||||
/// <returns>The client's IP endpoint (IPv4-mapped addresses normalised to IPv4), or null when unknown.</returns>
|
||||
public static IPEndPoint? Resolve(IEnumerable<ITransportListener>? listeners, string? secureChannelId)
|
||||
{
|
||||
if (listeners is null || string.IsNullOrEmpty(secureChannelId)) return null;
|
||||
try
|
||||
{
|
||||
foreach (var listener in listeners)
|
||||
{
|
||||
if (listener is null) continue;
|
||||
var listenerId = listener.ListenerId;
|
||||
if (string.IsNullOrEmpty(listenerId)
|
||||
|| !secureChannelId.StartsWith(listenerId + "-", StringComparison.Ordinal)
|
||||
|| !uint.TryParse(secureChannelId.AsSpan(listenerId.Length + 1), out var channelId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (FindChannel(listener, channelId) is { } channel && RemoteEndpointOf(channel) is { } endpoint)
|
||||
return endpoint;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fail-soft by contract: the caller falls back to process-wide accounting.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>The listener's channel with id <paramref name="channelId"/>, read from its private channel map.</summary>
|
||||
private static object? FindChannel(ITransportListener listener, uint channelId)
|
||||
{
|
||||
var field = FindField(listener.GetType(), "m_channels");
|
||||
if (field?.GetValue(listener) is not IDictionary channels) return null;
|
||||
return channels.Contains(channelId) ? channels[channelId] : null;
|
||||
}
|
||||
|
||||
/// <summary>The channel's socket's remote endpoint, as an <see cref="IPEndPoint"/>.</summary>
|
||||
private static IPEndPoint? RemoteEndpointOf(object channel)
|
||||
{
|
||||
var socketProperty = FindProperty(channel.GetType(), "Socket");
|
||||
var socket = socketProperty?.GetValue(channel);
|
||||
if (socket is null) return null;
|
||||
var remote = FindProperty(socket.GetType(), "RemoteEndpoint")?.GetValue(socket) as IPEndPoint;
|
||||
if (remote is null) return null;
|
||||
return remote.Address.IsIPv4MappedToIPv6 ? new IPEndPoint(remote.Address.MapToIPv4(), remote.Port) : remote;
|
||||
}
|
||||
|
||||
private static FieldInfo? FindField(Type? type, string name)
|
||||
{
|
||||
for (; type is not null; type = type.BaseType)
|
||||
{
|
||||
if (type.GetField(name, Instance | BindingFlags.DeclaredOnly) is { } field) return field;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static PropertyInfo? FindProperty(Type? type, string name)
|
||||
{
|
||||
for (; type is not null; type = type.BaseType)
|
||||
{
|
||||
if (type.GetProperty(name, Instance | BindingFlags.DeclaredOnly) is { } property) return property;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer" PrivateAssets="all" IncludeAssets="analyzers;build;buildtransitive" />
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server"/>
|
||||
<!-- All OPC UA packages aligned at the central 1.5.378.182 (Opc.Ua.Server 1.5.378.182 is now
|
||||
published). The prior VersionOverride pinned Opc.Ua.Configuration/Core to 1.5.374.126 to match
|
||||
the older Server, but the published Host unified Core to 378 anyway (Driver.OpcUaClient needs
|
||||
it) → Server 1.5.374 loaded against Core 1.5.378 and threw TypeLoadException(INodeIdFactory).
|
||||
The 1.5.378 SDK is a sync→async + ITelemetryContext break, ported in OpcUaApplicationHost. -->
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration"/>
|
||||
</ItemGroup>
|
||||
|
||||
@@ -29,6 +23,9 @@
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests"/>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests"/>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests"/>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests"/>
|
||||
<!-- R2-08 (03/S2): the outage-sim integration test drives the internal static
|
||||
OpcUaApplicationHost.HandleImpersonation against the real LDAP library. -->
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Host.IntegrationTests"/>
|
||||
|
||||
@@ -54,7 +54,7 @@ public class AlarmsCommandTests
|
||||
|
||||
fakeService.SubscribeAlarmsCalls.Count.ShouldBe(1);
|
||||
fakeService.SubscribeAlarmsCalls[0].SourceNodeId.ShouldNotBeNull();
|
||||
fakeService.SubscribeAlarmsCalls[0].SourceNodeId!.Identifier.ShouldBe("AlarmSource");
|
||||
fakeService.SubscribeAlarmsCalls[0].SourceNodeId!.Value.IdentifierAsString.ShouldBe("AlarmSource");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Execute with refresh requests condition refresh.</summary>
|
||||
|
||||
@@ -56,7 +56,7 @@ public class BrowseCommandTests
|
||||
|
||||
fakeService.BrowseNodeIds.Count.ShouldBe(1);
|
||||
fakeService.BrowseNodeIds[0].ShouldNotBeNull();
|
||||
fakeService.BrowseNodeIds[0]!.Identifier.ShouldBe("StartNode");
|
||||
fakeService.BrowseNodeIds[0]!.Value.IdentifierAsString.ShouldBe("StartNode");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Execute browses from null node when not specified.</summary>
|
||||
|
||||
@@ -61,7 +61,7 @@ public class HistoryReadCommandTests
|
||||
|
||||
fakeService.HistoryReadRawCalls.Count.ShouldBe(1);
|
||||
fakeService.HistoryReadRawCalls[0].MaxValues.ShouldBe(500);
|
||||
fakeService.HistoryReadRawCalls[0].NodeId.Identifier.ShouldBe("HistNode");
|
||||
fakeService.HistoryReadRawCalls[0].NodeId.IdentifierAsString.ShouldBe("HistNode");
|
||||
}
|
||||
|
||||
/// <summary>Verifies AggregateRead execution calls HistoryReadAggregate.</summary>
|
||||
@@ -211,11 +211,11 @@ public class HistoryReadCommandTests
|
||||
|
||||
var call = fakeService.HistoryReadEventsCalls.ShouldHaveSingleItem();
|
||||
call.MaxEvents.ShouldBe(25);
|
||||
call.Filter.SelectClauses.Select(c => c.BrowsePath.ShouldHaveSingleItem().Name)
|
||||
call.Filter.SelectClauses.ToArray()!.Select(c => c.BrowsePath.ToArray()!.ShouldHaveSingleItem().Name)
|
||||
.ShouldBe(["EventId", "EventType", "Time", "SourceName", "Severity", "Message", "ConditionName", "SourceNode"]);
|
||||
call.Filter.SelectClauses.Single(c => c.BrowsePath[0].Name == "ConditionName").TypeDefinitionId
|
||||
call.Filter.SelectClauses.ToArray()!.Single(c => c.BrowsePath[0].Name == "ConditionName").TypeDefinitionId
|
||||
.ShouldBe(ObjectTypeIds.ConditionType);
|
||||
call.Filter.WhereClause.Elements.ShouldBeEmpty("no --of-type, no where-clause");
|
||||
call.Filter.WhereClause.Elements.ToArray()!.ShouldBeEmpty("no --of-type, no where-clause");
|
||||
fakeService.HistoryReadRawCalls.ShouldBeEmpty();
|
||||
|
||||
var output = TestConsoleHelper.GetOutput(console);
|
||||
@@ -243,10 +243,10 @@ public class HistoryReadCommandTests
|
||||
await command.ExecuteAsync(console);
|
||||
|
||||
var filter = fakeService.HistoryReadEventsCalls.ShouldHaveSingleItem().Filter;
|
||||
filter.SelectClauses.Select(c => c.BrowsePath[0].Name).ShouldBe(["EventType", "Severity"]);
|
||||
var element = filter.WhereClause.Elements.ShouldHaveSingleItem();
|
||||
filter.SelectClauses.ToArray()!.Select(c => c.BrowsePath[0].Name).ShouldBe(["EventType", "Severity"]);
|
||||
var element = filter.WhereClause.Elements.ToArray()!.ShouldHaveSingleItem();
|
||||
element.FilterOperator.ShouldBe(FilterOperator.OfType);
|
||||
((LiteralOperand)ExtensionObject.ToEncodeable(element.FilterOperands[0])).Value.Value
|
||||
(element.FilterOperands[0].TryGetValue(out LiteralOperand? literal) ? literal.Value : Variant.Null)
|
||||
.ShouldBe(ObjectTypeIds.LimitAlarmType);
|
||||
TestConsoleHelper.GetOutput(console).ShouldContain("0 events returned.");
|
||||
}
|
||||
|
||||
@@ -33,9 +33,9 @@ public class HistoryUpdateCommandTests
|
||||
.BuildDetails().ShouldBeOfType<UpdateDataDetails>();
|
||||
d.PerformInsertReplace.ShouldBe(PerformUpdateType.Insert);
|
||||
d.UpdateValues.Count.ShouldBe(2);
|
||||
d.UpdateValues[1].Value.ShouldBe(2.5);
|
||||
d.UpdateValues[1].WrappedValue.ShouldBe(Variant.From(2.5));
|
||||
d.UpdateValues[0].SourceTimestamp.ShouldBe(new DateTime(2026, 1, 15, 8, 0, 0, DateTimeKind.Utc));
|
||||
d.NodeId.Identifier.ShouldBe("HistNode");
|
||||
d.NodeId.IdentifierAsString.ShouldBe("HistNode");
|
||||
}
|
||||
|
||||
/// <summary>Replace and update map to their update types.</summary>
|
||||
@@ -60,7 +60,7 @@ public class HistoryUpdateCommandTests
|
||||
{
|
||||
var d = Make(values: [value], times: ["2026-01-15T08:00:00Z"], type: type)
|
||||
.BuildDetails().ShouldBeOfType<UpdateDataDetails>();
|
||||
d.UpdateValues[0].Value.ShouldBeOfType(expected);
|
||||
ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(d.UpdateValues[0].WrappedValue).ShouldBeOfType(expected);
|
||||
}
|
||||
|
||||
/// <summary>delete-raw builds DeleteRawModifiedDetails with IsDeleteModified=false.</summary>
|
||||
@@ -211,7 +211,7 @@ public class HistoryUpdateCommandTests
|
||||
public async Task Execute_UnnamedStatus_PrintsHex()
|
||||
{
|
||||
const uint unnamed = 0x8FFF0000u;
|
||||
StatusCodes.GetBrowseName(unnamed).ShouldBeNullOrEmpty(); // precondition: genuinely unnamed
|
||||
new StatusCode(unnamed).SymbolicId.ShouldBeNullOrEmpty(); // precondition: genuinely unnamed
|
||||
var svc = new FakeOpcUaClientService
|
||||
{
|
||||
HistoryUpdateResult = new HistoryUpdateOutcome(StatusCodes.Good, [StatusCodes.Good],
|
||||
|
||||
@@ -34,8 +34,8 @@ public class NodeIdParserTests
|
||||
{
|
||||
var result = NodeIdParser.Parse("ns=2;s=MyNode");
|
||||
result.ShouldNotBeNull();
|
||||
result.NamespaceIndex.ShouldBe((ushort)2);
|
||||
result.Identifier.ShouldBe("MyNode");
|
||||
result.Value.NamespaceIndex.ShouldBe((ushort)2);
|
||||
result.Value.IdentifierAsString.ShouldBe("MyNode");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Parse correctly parses numeric NodeId format (i=85).</summary>
|
||||
@@ -44,7 +44,7 @@ public class NodeIdParserTests
|
||||
{
|
||||
var result = NodeIdParser.Parse("i=85");
|
||||
result.ShouldNotBeNull();
|
||||
result.IdType.ShouldBe(IdType.Numeric);
|
||||
result.Value.IdType.ShouldBe(IdType.Numeric);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Parse treats bare numeric input as namespace 0 numeric NodeId.</summary>
|
||||
@@ -53,8 +53,9 @@ public class NodeIdParserTests
|
||||
{
|
||||
var result = NodeIdParser.Parse("85");
|
||||
result.ShouldNotBeNull();
|
||||
result.NamespaceIndex.ShouldBe((ushort)0);
|
||||
result.Identifier.ShouldBe((uint)85);
|
||||
result.Value.NamespaceIndex.ShouldBe((ushort)0);
|
||||
result.Value.TryGetValue(out uint identifier).ShouldBeTrue();
|
||||
identifier.ShouldBe(85u);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Parse trims whitespace padding from input.</summary>
|
||||
@@ -63,7 +64,7 @@ public class NodeIdParserTests
|
||||
{
|
||||
var result = NodeIdParser.Parse(" ns=2;s=MyNode ");
|
||||
result.ShouldNotBeNull();
|
||||
result.Identifier.ShouldBe("MyNode");
|
||||
result.Value.IdentifierAsString.ShouldBe("MyNode");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Parse throws FormatException for invalid input.</summary>
|
||||
@@ -92,7 +93,36 @@ public class NodeIdParserTests
|
||||
public void ParseRequired_ValidInput_ReturnsNodeId()
|
||||
{
|
||||
var result = NodeIdParser.ParseRequired("ns=2;s=TestNode");
|
||||
result.ShouldNotBeNull();
|
||||
result.Identifier.ShouldBe("TestNode");
|
||||
result.IsNull.ShouldBeFalse();
|
||||
result.IdentifierAsString.ShouldBe("TestNode");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_OpaqueIdentifier_PreservesBytes()
|
||||
{
|
||||
var node = NodeIdParser.ParseRequired("ns=3;b=AQID");
|
||||
node.IdType.ShouldBe(IdType.Opaque);
|
||||
node.NamespaceIndex.ShouldBe((ushort)3);
|
||||
node.TryGetValue(out ByteString bytes).ShouldBeTrue();
|
||||
bytes.ToArray().ShouldBe(new byte[] { 1, 2, 3 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_StringIdentifierContainingEncodedText_StaysLiteral()
|
||||
{
|
||||
var node = NodeIdParser.ParseRequired("ns=4;s=ns=2;b=AQID");
|
||||
node.IdType.ShouldBe(IdType.String);
|
||||
node.NamespaceIndex.ShouldBe((ushort)4);
|
||||
node.TryGetValue(out string? identifier).ShouldBeTrue();
|
||||
identifier.ShouldBe("ns=2;b=AQID");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ns=bad;i=1")]
|
||||
[InlineData("ns=2;b=invalid!")]
|
||||
[InlineData("ns=2;i=invalid")]
|
||||
public void Parse_InvalidEncodedIdentifier_IsRejected(string input)
|
||||
{
|
||||
Should.Throw<FormatException>(() => NodeIdParser.ParseRequired(input));
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ public class ReadCommandTests
|
||||
await command.ExecuteAsync(console);
|
||||
|
||||
fakeService.ReadNodeIds.Count.ShouldBe(1);
|
||||
fakeService.ReadNodeIds[0].Identifier.ShouldBe("MyVariable");
|
||||
fakeService.ReadNodeIds[0].IdentifierAsString.ShouldBe("MyVariable");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that execute disconnects in finally.</summary>
|
||||
|
||||
@@ -222,8 +222,8 @@ public class SubscribeCommandSummaryTests
|
||||
|
||||
// Both Variables (depth 1 + depth 2) should have been subscribed.
|
||||
fakeService.SubscribeCalls.Count.ShouldBeGreaterThanOrEqualTo(2);
|
||||
fakeService.SubscribeCalls.ShouldContain(c => c.NodeId.Identifier.ToString() == "Tag1");
|
||||
fakeService.SubscribeCalls.ShouldContain(c => c.NodeId.Identifier.ToString() == "Tag2");
|
||||
fakeService.SubscribeCalls.ShouldContain(c => c.NodeId.IdentifierAsString.ToString() == "Tag1");
|
||||
fakeService.SubscribeCalls.ShouldContain(c => c.NodeId.IdentifierAsString.ToString() == "Tag2");
|
||||
|
||||
var output = TestConsoleHelper.GetOutput(console);
|
||||
output.ShouldContain("Browsing subtree of ns=2;s=Root");
|
||||
|
||||
@@ -33,7 +33,7 @@ public class SubscribeCommandTests
|
||||
|
||||
fakeService.SubscribeCalls.Count.ShouldBe(1);
|
||||
fakeService.SubscribeCalls[0].IntervalMs.ShouldBe(500);
|
||||
fakeService.SubscribeCalls[0].NodeId.Identifier.ShouldBe("TestVar");
|
||||
fakeService.SubscribeCalls[0].NodeId.IdentifierAsString.ShouldBe("TestVar");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ExecuteAsync unsubscribes when cancellation is requested.</summary>
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Adapters;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Adapters;
|
||||
|
||||
/// <summary>Uses the real configuration factory so fake sessions cannot conceal SDK initialization failures.</summary>
|
||||
public sealed class DefaultApplicationConfigurationFactoryTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(SecurityMode.None)]
|
||||
[InlineData(SecurityMode.SignAndEncrypt)]
|
||||
public async Task Real_configuration_initializes_its_certificate_manager(SecurityMode mode)
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), $"otopcua-real-config-{Guid.NewGuid():N}");
|
||||
ApplicationConfiguration? config = null;
|
||||
try
|
||||
{
|
||||
config = await new DefaultApplicationConfigurationFactory().CreateAsync(new ConnectionSettings
|
||||
{
|
||||
EndpointUrl = "opc.tcp://localhost:4840",
|
||||
CertificateStorePath = root,
|
||||
SecurityMode = mode,
|
||||
AutoAcceptCertificates = false,
|
||||
}, TestContext.Current.CancellationToken);
|
||||
config.CertificateManager.ShouldNotBeNull();
|
||||
config.SecurityConfiguration.AutoAcceptUntrustedCertificates.ShouldBeFalse();
|
||||
if (mode != SecurityMode.None)
|
||||
Directory.EnumerateFiles(Path.Combine(root, "own"), "*", SearchOption.AllDirectories).ShouldNotBeEmpty();
|
||||
}
|
||||
finally
|
||||
{
|
||||
(config?.CertificateManager as IDisposable)?.Dispose();
|
||||
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -11,6 +11,48 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Adapters;
|
||||
/// </summary>
|
||||
public class DefaultSubscriptionAdapterTests
|
||||
{
|
||||
private sealed class CountingSubscription() : Subscription(ClientTelemetry.Context)
|
||||
{
|
||||
public int DisposeCount;
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing) Interlocked.Increment(ref DisposeCount);
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAndDispose_ReleaseSdkResourcesExactlyOnce()
|
||||
{
|
||||
var subscription = new CountingSubscription();
|
||||
var detached = 0;
|
||||
var adapter = new DefaultSubscriptionAdapter(subscription, (_, _) =>
|
||||
{
|
||||
Interlocked.Increment(ref detached);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
await adapter.DeleteAsync(TestContext.Current.CancellationToken);
|
||||
adapter.Dispose();
|
||||
adapter.Dispose();
|
||||
detached.ShouldBe(1);
|
||||
subscription.DisposeCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentTeardown_HasOneSdkResourceOwner()
|
||||
{
|
||||
var subscription = new CountingSubscription();
|
||||
var detached = 0;
|
||||
var adapter = new DefaultSubscriptionAdapter(subscription, (_, _) =>
|
||||
{
|
||||
Interlocked.Increment(ref detached);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
await Task.WhenAll(Task.Run(adapter.Dispose, TestContext.Current.CancellationToken), adapter.DeleteAsync(TestContext.Current.CancellationToken));
|
||||
detached.ShouldBe(1);
|
||||
subscription.DisposeCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>DeleteAsync detaches the wrapped subscription exactly once, even when Dispose follows.</summary>
|
||||
[Fact]
|
||||
public async Task DeleteAsync_detaches_the_subscription_from_its_session_once()
|
||||
|
||||
+6
-6
@@ -117,7 +117,7 @@ public class EndpointSelectorTests
|
||||
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://external-host:4840",
|
||||
MessageSecurityMode.Sign);
|
||||
|
||||
new Uri(best.EndpointUrl).Host.ShouldBe("external-host");
|
||||
new Uri(best.EndpointUrl.ShouldNotBeNull()).Host.ShouldBe("external-host");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -169,7 +169,7 @@ public class EndpointSelectorTests
|
||||
|
||||
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://localhost:4841", MessageSecurityMode.None);
|
||||
|
||||
var uri = new Uri(best.EndpointUrl);
|
||||
var uri = new Uri(best.EndpointUrl.ShouldNotBeNull());
|
||||
uri.Host.ShouldBe("localhost");
|
||||
uri.Port.ShouldBe(4841);
|
||||
uri.AbsolutePath.ShouldBe("/OtOpcUa");
|
||||
@@ -183,8 +183,8 @@ public class EndpointSelectorTests
|
||||
|
||||
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://localhost:4841", MessageSecurityMode.None);
|
||||
|
||||
new Uri(best.EndpointUrl).Host.ShouldBe("internal-host");
|
||||
new Uri(best.EndpointUrl).Port.ShouldBe(4843);
|
||||
new Uri(best.EndpointUrl.ShouldNotBeNull()).Host.ShouldBe("internal-host");
|
||||
new Uri(best.EndpointUrl.ShouldNotBeNull()).Port.ShouldBe(4843);
|
||||
}
|
||||
|
||||
/// <summary>Identical host, port and scheme: nothing to rewrite.</summary>
|
||||
@@ -206,7 +206,7 @@ public class EndpointSelectorTests
|
||||
|
||||
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://localhost", MessageSecurityMode.None);
|
||||
|
||||
var uri = new Uri(best.EndpointUrl);
|
||||
var uri = new Uri(best.EndpointUrl.ShouldNotBeNull());
|
||||
uri.Host.ShouldBe("localhost");
|
||||
uri.Port.ShouldBe(4840);
|
||||
}
|
||||
@@ -219,7 +219,7 @@ public class EndpointSelectorTests
|
||||
|
||||
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://[::1]:4841", MessageSecurityMode.None);
|
||||
|
||||
var uri = new Uri(best.EndpointUrl);
|
||||
var uri = new Uri(best.EndpointUrl.ShouldNotBeNull());
|
||||
uri.Host.ShouldBe("[::1]");
|
||||
uri.Port.ShouldBe(4841);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ public sealed class AlarmEventFilterTests
|
||||
{
|
||||
var filter = OpcUaClientService.CreateAlarmEventFilter();
|
||||
|
||||
filter.SelectClauses.SelectMany(c => c.BrowsePath).ShouldAllBe(q => !q.Name.Contains('/'));
|
||||
filter.SelectClauses.ToArray()!.SelectMany(c => c.BrowsePath.ToArray()!).ShouldAllBe(q => !(q.Name ?? "").Contains('/'));
|
||||
}
|
||||
|
||||
/// <summary>The two-state ids are selected by their two-element browse paths, at the indices the parser reads.</summary>
|
||||
@@ -27,7 +27,7 @@ public sealed class AlarmEventFilterTests
|
||||
{
|
||||
var clauses = OpcUaClientService.CreateAlarmEventFilter().SelectClauses;
|
||||
|
||||
string Path(int i) => string.Join("/", clauses[i].BrowsePath.Select(q => q.Name));
|
||||
string Path(int i) => string.Join("/", clauses[i].BrowsePath.ToArray()!.Select(q => q.Name));
|
||||
Path(8).ShouldBe("AckedState/Id");
|
||||
clauses[8].BrowsePath.Count.ShouldBe(2);
|
||||
Path(9).ShouldBe("ActiveState/Id");
|
||||
|
||||
@@ -164,7 +164,7 @@ public sealed class FailoverEpochTests : IDisposable
|
||||
var sub = new FakeSubscriptionAdapter();
|
||||
_factory.EnqueueSession(new FakeSessionAdapter { NextSubscription = sub });
|
||||
await _service.ConnectAsync(Settings(), TestContext.Current.CancellationToken);
|
||||
var node = new Opc.Ua.NodeId("ns=2;s=X");
|
||||
var node = Opc.Ua.NodeId.Parse("ns=2;s=X");
|
||||
await _service.SubscribeAsync(node, ct: TestContext.Current.CancellationToken);
|
||||
|
||||
sub.ThrowOnRemove = true;
|
||||
|
||||
@@ -35,7 +35,7 @@ internal sealed class FakeEndpointDiscovery : IEndpointDiscovery
|
||||
: SecurityPolicies.Basic256Sha256,
|
||||
Server = new ApplicationDescription
|
||||
{
|
||||
ApplicationName = "FakeServer",
|
||||
ApplicationName = new LocalizedText("FakeServer"),
|
||||
ApplicationUri = "urn:localhost:FakeServer"
|
||||
}
|
||||
});
|
||||
|
||||
@@ -48,11 +48,15 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
|
||||
public bool ThrowOnBrowse { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the browse references returned by browse operations.</summary>
|
||||
public ReferenceDescriptionCollection BrowseResponse { get; set; } = [];
|
||||
public ArrayOf<ReferenceDescription> BrowseResponse { get; set; } = [];
|
||||
/// <summary>Gets or sets the continuation point for browse operations.</summary>
|
||||
public byte[]? BrowseContinuationPoint { get; set; }
|
||||
public Func<CancellationToken, Task>? BeforeBrowseNext { get; set; }
|
||||
public List<(byte[] Point, bool Cancelled)> BrowseReleases { get; } = [];
|
||||
public bool ThrowOnBrowseRelease { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the browse references returned by browse-next operations.</summary>
|
||||
public ReferenceDescriptionCollection BrowseNextResponse { get; set; } = [];
|
||||
public ArrayOf<ReferenceDescription> BrowseNextResponse { get; set; } = [];
|
||||
/// <summary>Gets or sets the continuation point for browse-next operations.</summary>
|
||||
public byte[]? BrowseNextContinuationPoint { get; set; }
|
||||
|
||||
@@ -129,7 +133,7 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync(
|
||||
public Task<(byte[]? ContinuationPoint, ArrayOf<ReferenceDescription> References)> BrowseAsync(
|
||||
NodeId nodeId, uint nodeClassMask, CancellationToken ct)
|
||||
{
|
||||
BrowseCount++;
|
||||
@@ -139,11 +143,19 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
|
||||
public async Task<(byte[]? ContinuationPoint, ArrayOf<ReferenceDescription> References)> BrowseNextAsync(
|
||||
byte[] continuationPoint, CancellationToken ct)
|
||||
{
|
||||
BrowseNextCount++;
|
||||
return Task.FromResult((BrowseNextContinuationPoint, BrowseNextResponse));
|
||||
if (BeforeBrowseNext != null) await BeforeBrowseNext(ct);
|
||||
return (BrowseNextContinuationPoint, BrowseNextResponse);
|
||||
}
|
||||
|
||||
public Task ReleaseBrowseContinuationPointAsync(byte[] continuationPoint, CancellationToken ct)
|
||||
{
|
||||
BrowseReleases.Add((continuationPoint, ct.IsCancellationRequested));
|
||||
if (ThrowOnBrowseRelease) throw new ServiceResultException(StatusCodes.BadSessionClosed);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -40,7 +40,7 @@ internal sealed class FakeSessionFactory : ISessionFactory
|
||||
uint sessionTimeoutMs, UserIdentity identity, CancellationToken ct)
|
||||
{
|
||||
CreateCallCount++;
|
||||
LastEndpointUrl = endpoint.EndpointUrl;
|
||||
LastEndpointUrl = endpoint.EndpointUrl ?? string.Empty;
|
||||
|
||||
// One-shot: the gate holds only the call that observed it, so a later call (e.g. a user's Connect while a
|
||||
// failover is held) runs freely.
|
||||
@@ -60,14 +60,14 @@ internal sealed class FakeSessionFactory : ISessionFactory
|
||||
else
|
||||
session = new FakeSessionAdapter
|
||||
{
|
||||
EndpointUrl = endpoint.EndpointUrl,
|
||||
ServerName = endpoint.Server?.ApplicationName?.Text ?? "FakeServer",
|
||||
EndpointUrl = endpoint.EndpointUrl ?? string.Empty,
|
||||
ServerName = endpoint.Server?.ApplicationName.Text ?? "FakeServer",
|
||||
SecurityMode = endpoint.SecurityMode.ToString(),
|
||||
SecurityPolicyUri = endpoint.SecurityPolicyUri ?? string.Empty
|
||||
};
|
||||
|
||||
// Ensure endpoint URL matches
|
||||
session.EndpointUrl = endpoint.EndpointUrl;
|
||||
session.EndpointUrl = endpoint.EndpointUrl ?? string.Empty;
|
||||
_createdSessions.Add(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -129,6 +129,16 @@ internal sealed class FakeSubscriptionAdapter : ISubscriptionAdapter
|
||||
lock (_itemsLock) _items.Clear();
|
||||
}
|
||||
|
||||
public Action<string, DataValue> CaptureDataCallback(uint handle)
|
||||
{
|
||||
lock (_itemsLock) return _items[handle].DataCallback!;
|
||||
}
|
||||
|
||||
public Action<EventFieldList> CaptureEventCallback(uint handle)
|
||||
{
|
||||
lock (_itemsLock) return _items[handle].EventCallback!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulates a data change notification for testing.
|
||||
/// </summary>
|
||||
|
||||
+4
-5
@@ -26,8 +26,7 @@ public class AggregateTypeMapperTests
|
||||
public void ToNodeId_ReturnsNonNullForAllValues(AggregateType aggregate)
|
||||
{
|
||||
var nodeId = AggregateTypeMapper.ToNodeId(aggregate);
|
||||
nodeId.ShouldNotBeNull();
|
||||
nodeId.IsNullNodeId.ShouldBeFalse();
|
||||
nodeId.IsNull.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Average aggregate type maps to the correct OPC UA node ID.</summary>
|
||||
@@ -109,9 +108,9 @@ public class AggregateTypeMapperTests
|
||||
[MemberData(nameof(AllAggregates))]
|
||||
public void ToNodeId_maps_to_the_same_named_AggregateFunction(AggregateType aggregate)
|
||||
{
|
||||
var field = typeof(ObjectIds).GetField("AggregateFunction_" + aggregate);
|
||||
field.ShouldNotBeNull();
|
||||
AggregateTypeMapper.ToNodeId(aggregate).ShouldBe((NodeId)field.GetValue(null)!);
|
||||
var property = typeof(ObjectIds).GetProperty("AggregateFunction_" + aggregate);
|
||||
property.ShouldNotBeNull();
|
||||
AggregateTypeMapper.ToNodeId(aggregate).ShouldBe((NodeId)property.GetValue(null)!);
|
||||
}
|
||||
|
||||
/// <summary>Every client aggregate.</summary>
|
||||
|
||||
+26
-10
@@ -29,19 +29,18 @@ public class HistoryReadPagingTests
|
||||
Calls.Add(new Call(cp, release));
|
||||
if (release) return Task.FromResult<HistoryReadResult?>(new HistoryReadResult { StatusCode = StatusCodes.Good });
|
||||
var (count, next) = _pages.Dequeue();
|
||||
var data = new HistoryData();
|
||||
for (var i = 0; i < count; i++) data.DataValues.Add(new DataValue(new Variant(i)));
|
||||
var data = new HistoryData { DataValues = Enumerable.Range(0, count).Select(i => new DataValue(Variant.From(i))).ToArrayOf() };
|
||||
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
|
||||
{
|
||||
StatusCode = StatusCodes.Good,
|
||||
ContinuationPoint = next,
|
||||
ContinuationPoint = ByteString.From(next),
|
||||
HistoryData = new ExtensionObject(data)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<DataValue> Values(HistoryReadResult r) =>
|
||||
r.HistoryData?.Body is HistoryData d ? d.DataValues : [];
|
||||
r.HistoryData.TryGetValue(out HistoryData? d) ? d.DataValues.ToArray() ?? [] : [];
|
||||
|
||||
/// <summary>
|
||||
/// A server page limit below the caller's cap hands back a continuation point: the follow-up must READ the next
|
||||
@@ -136,8 +135,7 @@ public class HistoryReadPagingTests
|
||||
calls.Add((cp, release, ct.IsCancellationRequested));
|
||||
if (release) return Task.FromResult<HistoryReadResult?>(new HistoryReadResult { StatusCode = StatusCodes.Good });
|
||||
if (cp != null) { cts.Cancel(); ct.ThrowIfCancellationRequested(); }
|
||||
var data = new HistoryData();
|
||||
data.DataValues.Add(new DataValue(new Variant(1)));
|
||||
var data = new HistoryData { DataValues = [new DataValue(Variant.From(1))] };
|
||||
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
|
||||
{
|
||||
StatusCode = StatusCodes.Good, ContinuationPoint = [5], HistoryData = new ExtensionObject(data)
|
||||
@@ -165,8 +163,7 @@ public class HistoryReadPagingTests
|
||||
releaseSawCancelled = ct.IsCancellationRequested;
|
||||
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult { StatusCode = StatusCodes.Good });
|
||||
}
|
||||
var data = new HistoryData();
|
||||
data.DataValues.Add(new DataValue(new Variant(1)));
|
||||
var data = new HistoryData { DataValues = [new DataValue(Variant.From(1))] };
|
||||
cts.Cancel(); // the caller gives up right as the capped page arrives
|
||||
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
|
||||
{
|
||||
@@ -189,8 +186,7 @@ public class HistoryReadPagingTests
|
||||
{
|
||||
calls++;
|
||||
if (release) throw new ServiceResultException(StatusCodes.BadSessionClosed);
|
||||
var data = new HistoryData();
|
||||
data.DataValues.Add(new DataValue(new Variant(1)));
|
||||
var data = new HistoryData { DataValues = [new DataValue(Variant.From(1))] };
|
||||
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
|
||||
{
|
||||
StatusCode = StatusCodes.Good, ContinuationPoint = [9], HistoryData = new ExtensionObject(data)
|
||||
@@ -216,4 +212,24 @@ public class HistoryReadPagingTests
|
||||
await Should.ThrowAsync<ServiceResultException>(() =>
|
||||
HistoryReadPaging.ReadAllAsync(Read, Values, maxValues: 10, Node, CancellationToken.None));
|
||||
}
|
||||
[Fact]
|
||||
public async Task RefusedContinuationPage_ReleasesCursorAndPreservesStatus()
|
||||
{
|
||||
var releases = new List<byte[]>();
|
||||
Task<HistoryReadResult?> Read(byte[]? cp, bool release, CancellationToken ct)
|
||||
{
|
||||
if (release)
|
||||
{
|
||||
releases.Add(cp!);
|
||||
throw new ServiceResultException(StatusCodes.BadSessionClosed);
|
||||
}
|
||||
return Task.FromResult<HistoryReadResult?>(cp == null
|
||||
? new HistoryReadResult { StatusCode = StatusCodes.Good, ContinuationPoint = [7] }
|
||||
: new HistoryReadResult { StatusCode = StatusCodes.BadUserAccessDenied });
|
||||
}
|
||||
var exception = await Should.ThrowAsync<ServiceResultException>(() =>
|
||||
HistoryReadPaging.ReadAllAsync(Read, Values, 10, Node, TestContext.Current.CancellationToken));
|
||||
exception.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
releases.ShouldHaveSingleItem().ShouldBe(new byte[] { 7 });
|
||||
}
|
||||
}
|
||||
|
||||
+11
-14
@@ -7,32 +7,29 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Helpers;
|
||||
|
||||
public class HistoryReadStatusTests
|
||||
{
|
||||
public static IEnumerable<object[]> Refusals() => new[] { StatusCodes.BadHistoryOperationUnsupported,
|
||||
StatusCodes.BadUserAccessDenied, StatusCodes.BadNodeIdUnknown, StatusCodes.BadTimeout }.Select(s => new object[] { s });
|
||||
public static IEnumerable<object[]> NonRefusals() => new[] { StatusCodes.Good, StatusCodes.GoodNoData,
|
||||
StatusCodes.GoodMoreData, StatusCodes.UncertainNoCommunicationLastUsableValue, StatusCodes.BadNoData }.Select(s => new object[] { s });
|
||||
/// <summary>A refused read must surface, carrying the status code, rather than read as an empty result.</summary>
|
||||
/// <param name="code">The Bad per-node status code.</param>
|
||||
[Theory]
|
||||
[InlineData(StatusCodes.BadHistoryOperationUnsupported)]
|
||||
[InlineData(StatusCodes.BadUserAccessDenied)]
|
||||
[InlineData(StatusCodes.BadNodeIdUnknown)]
|
||||
[InlineData(StatusCodes.BadTimeout)]
|
||||
public void Bad_status_throws_ServiceResultException_with_the_code(uint code)
|
||||
[MemberData(nameof(Refusals))]
|
||||
public void Bad_status_throws_ServiceResultException_with_the_code(StatusCode code)
|
||||
{
|
||||
var ex = Should.Throw<ServiceResultException>(
|
||||
() => HistoryReadStatus.ThrowIfRefused(new StatusCode(code), new NodeId("n", 2)));
|
||||
() => HistoryReadStatus.ThrowIfRefused(code, new NodeId("n", 2)));
|
||||
|
||||
ex.StatusCode.ShouldBe(code);
|
||||
ex.StatusCode.ShouldBe(code.Code);
|
||||
ex.Message.ShouldContain("ns=2;s=n");
|
||||
}
|
||||
|
||||
/// <summary>Good, GoodNoData, Uncertain and BadNoData are all non-refusals (the window may simply be empty).</summary>
|
||||
/// <param name="code">The non-refusal status code.</param>
|
||||
[Theory]
|
||||
[InlineData(StatusCodes.Good)]
|
||||
[InlineData(StatusCodes.GoodNoData)]
|
||||
[InlineData(StatusCodes.GoodMoreData)]
|
||||
[InlineData(StatusCodes.UncertainNoCommunicationLastUsableValue)]
|
||||
[InlineData(StatusCodes.BadNoData)]
|
||||
public void Non_refusal_status_does_not_throw(uint code)
|
||||
[MemberData(nameof(NonRefusals))]
|
||||
public void Non_refusal_status_does_not_throw(StatusCode code)
|
||||
{
|
||||
Should.NotThrow(() => HistoryReadStatus.ThrowIfRefused(new StatusCode(code), new NodeId("n", 2)));
|
||||
Should.NotThrow(() => HistoryReadStatus.ThrowIfRefused(code, new NodeId("n", 2)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Helpers;
|
||||
|
||||
public class VariantBoundaryTests
|
||||
{
|
||||
public static IEnumerable<object?[]> Payloads()
|
||||
{
|
||||
foreach (var value in new object?[] { null, true, (sbyte)-1, (byte)1, (short)-2, (ushort)2,
|
||||
-3, 3u, -4L, 4UL, 1.25f, 2.5, "", "text", Guid.Parse("de305d54-75b4-431b-adb2-eb6b9e546014"),
|
||||
new DateTime(2026, 11, 10, 0, 0, 0, DateTimeKind.Utc), Array.Empty<byte>(), new byte[] { 1, 2 },
|
||||
Array.Empty<int>(), new[] { 1 }, new[] { 1, 2 }, Array.Empty<string>(), new[] { "", "text" } })
|
||||
yield return [value];
|
||||
foreach (var value in new object[] { true, (sbyte)-1, (byte)1, (short)-2, (ushort)2,
|
||||
-3, 3u, -4L, 4UL, 1.25f, 2.5, "text", Guid.Empty,
|
||||
new DateTime(2026, 11, 10, 0, 0, 0, DateTimeKind.Utc) })
|
||||
{
|
||||
foreach (var count in new[] { 0, 1, 3 })
|
||||
{
|
||||
var array = Array.CreateInstance(value.GetType(), count);
|
||||
for (var i = 0; i < count; i++) array.SetValue(value, i);
|
||||
yield return [array];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Payloads))]
|
||||
public void RoundTrip_PreservesClrTypeAndValue(object? expected)
|
||||
{
|
||||
var actual = ValueConverter.ToClrObject(ValueConverter.ToVariant(expected));
|
||||
if (expected == null) actual.ShouldBeNull();
|
||||
else
|
||||
{
|
||||
actual.ShouldNotBeNull();
|
||||
actual.GetType().ShouldBe(expected.GetType());
|
||||
actual.ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetainedArrays_AreDetachedFromCaller()
|
||||
{
|
||||
var bytes = new byte[] { 1, 2 };
|
||||
var numbers = new[] { 3, 4 };
|
||||
var byteValue = ValueConverter.ToVariant(bytes);
|
||||
var numericValue = ValueConverter.ToVariant(numbers);
|
||||
bytes[0] = 99;
|
||||
numbers[0] = 99;
|
||||
ValueConverter.ToClrObject(byteValue).ShouldBe(new byte[] { 1, 2 });
|
||||
ValueConverter.ToClrObject(numericValue).ShouldBe(new[] { 3, 4 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ByteString_NullAndEmptyRemainDistinct()
|
||||
{
|
||||
ValueConverter.ToClrObject(Variant.From(default(ByteString))).ShouldBeNull();
|
||||
ValueConverter.ToClrObject(Variant.From(ByteString.Empty)).ShouldBe(Array.Empty<byte>());
|
||||
ValueConverter.ToClrObject(Variant.Null).ShouldBeNull();
|
||||
ValueConverter.ToClrObject(Variant.From(0)).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullWrapperInputs_RemainNullAfterRetention()
|
||||
{
|
||||
ValueConverter.ToClrObject(ValueConverter.ToVariant(default(ByteString))).ShouldBeNull();
|
||||
ValueConverter.ToClrObject(ValueConverter.ToVariant(default(ArrayOf<int>))).ShouldBeNull();
|
||||
ValueConverter.ToVariant(default(ArrayOf<byte>)).TypeInfo.ValueRank.ShouldBe(ValueRanks.OneDimension);
|
||||
ValueConverter.ToClrObject(ValueConverter.ToVariant(Variant.From(default(ByteString)))).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadOnlyWrappers_AreDetachedFromMutableBackingBuffers()
|
||||
{
|
||||
var bytes = new byte[] { 1, 2 };
|
||||
var numbers = new[] { 3, 4 };
|
||||
var retainedBytes = ValueConverter.ToVariant(new ByteString(bytes));
|
||||
var retainedNumbers = ValueConverter.ToVariant(new ArrayOf<int>(numbers));
|
||||
bytes[0] = 99;
|
||||
numbers[0] = 99;
|
||||
ValueConverter.ToClrObject(retainedBytes).ShouldBe(new byte[] { 1, 2 });
|
||||
ValueConverter.ToClrObject(retainedNumbers).ShouldBe(new[] { 3, 4 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetainedByteStringArray_CopiesElementBuffersAndPreservesNullEmpty()
|
||||
{
|
||||
var bytes = new byte[] { 1, 2 };
|
||||
var value = ValueConverter.ToVariant(new ByteString[] { default, ByteString.Empty, new(bytes) });
|
||||
bytes[0] = 99;
|
||||
var actual = ValueConverter.ToClrObject(value).ShouldBeOfType<byte[][]>();
|
||||
actual[0].ShouldBeNull();
|
||||
actual[1].ShouldBeEmpty();
|
||||
actual[2].ShouldBe(new byte[] { 1, 2 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ByteArrayAndByteString_KeepWireRank()
|
||||
{
|
||||
ValueConverter.ToVariant(new byte[] { 1 }).TypeInfo.BuiltInType.ShouldBe(BuiltInType.ByteString);
|
||||
ValueConverter.ToVariant(new ArrayOf<byte>(new byte[] { 1 })).TypeInfo.BuiltInType.ShouldBe(BuiltInType.Byte);
|
||||
ValueConverter.ToVariant(new ArrayOf<byte>(new byte[] { 1 })).TypeInfo.ValueRank.ShouldBe(ValueRanks.OneDimension);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NodeIds_KeepOpaqueBytesAndLiteralStringIdentifiers()
|
||||
{
|
||||
var opaque = NodeId.Parse("ns=2;b=AQID");
|
||||
ValueConverter.ToClrObject(ValueConverter.ToVariant(opaque)).ShouldBe(opaque);
|
||||
var literal = new NodeId("ns=2;s=literal", 4);
|
||||
var result = ValueConverter.ToClrObject(ValueConverter.ToVariant(literal)).ShouldBeOfType<NodeId>();
|
||||
result.NamespaceIndex.ShouldBe((ushort)4);
|
||||
result.TryGetValue(out string? identifier).ShouldBeTrue();
|
||||
identifier.ShouldBe("ns=2;s=literal");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnsupportedShape_IsRejected()
|
||||
{
|
||||
Should.Throw<ServiceResultException>(() => ValueConverter.ToVariant(new int[2, 2]));
|
||||
Should.Throw<ServiceResultException>(() => ValueConverter.ToVariant(new object()));
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ public class ModelConstructionTests
|
||||
|
||||
args.NodeId.ShouldBe("ns=2;s=Temp");
|
||||
args.Value.ShouldBe(value);
|
||||
args.Value.Value.ShouldBe(42);
|
||||
args.Value.WrappedValue.ShouldBe(Variant.From(42));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ConnectionStateChangedEventArgs constructs correctly with all properties.</summary>
|
||||
|
||||
@@ -197,9 +197,9 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
var result = await _service.ReadValueAsync(new NodeId("ns=2;s=MyNode"), TestContext.Current.CancellationToken);
|
||||
var result = await _service.ReadValueAsync(NodeId.Parse("ns=2;s=MyNode"), TestContext.Current.CancellationToken);
|
||||
|
||||
result.Value.ShouldBe(42);
|
||||
result.WrappedValue.ShouldBe(Variant.From(42));
|
||||
session.ReadCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
public async Task ReadValueAsync_WhenDisconnected_Throws()
|
||||
{
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
_service.ReadValueAsync(new NodeId("ns=2;s=MyNode")));
|
||||
_service.ReadValueAsync(NodeId.Parse("ns=2;s=MyNode")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -224,7 +224,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
await Should.ThrowAsync<ServiceResultException>(() =>
|
||||
_service.ReadValueAsync(new NodeId("ns=2;s=MyNode")));
|
||||
_service.ReadValueAsync(NodeId.Parse("ns=2;s=MyNode")));
|
||||
}
|
||||
|
||||
// --- Write tests ---
|
||||
@@ -243,7 +243,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
var result = await _service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), 42, TestContext.Current.CancellationToken);
|
||||
var result = await _service.WriteValueAsync(NodeId.Parse("ns=2;s=MyNode"), 42, TestContext.Current.CancellationToken);
|
||||
|
||||
result.ShouldBe(StatusCodes.Good);
|
||||
session.WriteCount.ShouldBe(1);
|
||||
@@ -262,7 +262,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
await _service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), "42", TestContext.Current.CancellationToken);
|
||||
await _service.WriteValueAsync(NodeId.Parse("ns=2;s=MyNode"), "42", TestContext.Current.CancellationToken);
|
||||
|
||||
session.WriteCount.ShouldBe(1);
|
||||
session.ReadCount.ShouldBe(1); // Read for type inference
|
||||
@@ -278,7 +278,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
await _service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), 42, TestContext.Current.CancellationToken);
|
||||
await _service.WriteValueAsync(NodeId.Parse("ns=2;s=MyNode"), 42, TestContext.Current.CancellationToken);
|
||||
|
||||
session.WriteCount.ShouldBe(1);
|
||||
session.ReadCount.ShouldBe(0); // No read for non-string values
|
||||
@@ -291,7 +291,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
public async Task WriteValueAsync_WhenDisconnected_Throws()
|
||||
{
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
_service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), 42));
|
||||
_service.WriteValueAsync(NodeId.Parse("ns=2;s=MyNode"), 42));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -303,13 +303,13 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
{
|
||||
var session = new FakeSessionAdapter
|
||||
{
|
||||
ReadResponse = new DataValue(StatusCodes.BadNodeIdUnknown) // Bad status, null Value
|
||||
ReadResponse = DataValue.FromStatusCode(StatusCodes.BadNodeIdUnknown) // Bad status, null Value
|
||||
};
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
_service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), "42"));
|
||||
_service.WriteValueAsync(NodeId.Parse("ns=2;s=MyNode"), "42"));
|
||||
|
||||
ex.Message.ShouldContain("Cannot infer target type");
|
||||
}
|
||||
@@ -323,17 +323,90 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
{
|
||||
var session = new FakeSessionAdapter
|
||||
{
|
||||
ReadResponse = new DataValue(StatusCodes.BadNotReadable)
|
||||
ReadResponse = DataValue.FromStatusCode(StatusCodes.BadNotReadable)
|
||||
};
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
_service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), "42"));
|
||||
_service.WriteValueAsync(NodeId.Parse("ns=2;s=MyNode"), "42"));
|
||||
|
||||
ex.Message.ShouldContain("ns=2;s=MyNode");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task BrowseCancellation_ReleasesOwnedCursor_WithoutMaskingCancellation(bool releaseFails)
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
var session = new FakeSessionAdapter
|
||||
{
|
||||
BrowseResponse = [new ReferenceDescription { NodeId = new ExpandedNodeId("child", 2) }],
|
||||
BrowseContinuationPoint = [7],
|
||||
ThrowOnBrowseRelease = releaseFails,
|
||||
BeforeBrowseNext = token => { cts.Cancel(); token.ThrowIfCancellationRequested(); return Task.CompletedTask; }
|
||||
};
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
await Should.ThrowAsync<OperationCanceledException>(() => _service.BrowseAsync(ct: cts.Token));
|
||||
var release = session.BrowseReleases.ShouldHaveSingleItem();
|
||||
release.Point.ShouldBe(new byte[] { 7 });
|
||||
release.Cancelled.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BrowseEmptyPage_ReleasesRemainingCursor()
|
||||
{
|
||||
var session = new FakeSessionAdapter { BrowseContinuationPoint = [8] };
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
(await _service.BrowseAsync(ct: TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
||||
session.BrowseReleases.ShouldHaveSingleItem().Point.ShouldBe(new byte[] { 8 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadValue_PreservesSdkStatusTimestampsAndPicoseconds()
|
||||
{
|
||||
var source = new DateTime(2026, 11, 10, 12, 0, 0, DateTimeKind.Utc);
|
||||
var value = new DataValue(Variant.From(12.5), StatusCodes.UncertainLastUsableValue,
|
||||
source, source.AddSeconds(1), 12, 34);
|
||||
var session = new FakeSessionAdapter { ReadResponse = value };
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
var actual = await _service.ReadValueAsync(new NodeId("tag", 2), TestContext.Current.CancellationToken);
|
||||
actual.ShouldBe(value);
|
||||
actual.StatusCode.ShouldBe(StatusCodes.UncertainLastUsableValue);
|
||||
actual.SourcePicoseconds.ShouldBe((ushort)12);
|
||||
actual.ServerPicoseconds.ShouldBe((ushort)34);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AlarmMapping_PreservesTimeEmptyTextAndRetainedEventId()
|
||||
{
|
||||
var session = new FakeSessionAdapter();
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
await _service.SubscribeAlarmsAsync(ct: TestContext.Current.CancellationToken);
|
||||
var timestamp = new DateTime(2026, 11, 10, 12, 0, 0, DateTimeKind.Utc);
|
||||
var bytes = new byte[] { 1, 2, 3 };
|
||||
AlarmEventArgs? received = null;
|
||||
_service.AlarmEvent += (_, e) => received = e;
|
||||
var subscription = session.CreatedSubscriptions.Single();
|
||||
subscription.SimulateEvent(subscription.ActiveHandles.Single(), new EventFieldList
|
||||
{
|
||||
EventFields = [Variant.From(new ByteString(bytes)), Variant.From(ObjectTypeIds.AlarmConditionType),
|
||||
Variant.From("source"), Variant.From((DateTimeUtc)timestamp), Variant.From(LocalizedText.Null),
|
||||
Variant.From((ushort)321), Variant.From("condition"), Variant.From(true)]
|
||||
});
|
||||
bytes[0] = 99;
|
||||
received.ShouldNotBeNull();
|
||||
received.Time.ShouldBe(timestamp);
|
||||
received.Message.ShouldBe("");
|
||||
received.EventId.ShouldBe(new byte[] { 1, 2, 3 });
|
||||
received.Severity.ShouldBe((ushort)321);
|
||||
}
|
||||
|
||||
// --- Browse tests ---
|
||||
|
||||
/// <summary>
|
||||
@@ -348,7 +421,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
[
|
||||
new ReferenceDescription
|
||||
{
|
||||
NodeId = new ExpandedNodeId("ns=2;s=Child1"),
|
||||
NodeId = ExpandedNodeId.Parse("ns=2;s=Child1"),
|
||||
DisplayName = new LocalizedText("Child1"),
|
||||
NodeClass = NodeClass.Variable
|
||||
}
|
||||
@@ -397,19 +470,19 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
[
|
||||
new ReferenceDescription
|
||||
{
|
||||
NodeId = new ExpandedNodeId("ns=2;s=Folder1"),
|
||||
NodeId = ExpandedNodeId.Parse("ns=2;s=Folder1"),
|
||||
DisplayName = new LocalizedText("Folder1"),
|
||||
NodeClass = NodeClass.Object
|
||||
},
|
||||
new ReferenceDescription
|
||||
{
|
||||
NodeId = new ExpandedNodeId("ns=2;s=Folder2"),
|
||||
NodeId = ExpandedNodeId.Parse("ns=2;s=Folder2"),
|
||||
DisplayName = new LocalizedText("Folder2"),
|
||||
NodeClass = NodeClass.Object
|
||||
},
|
||||
new ReferenceDescription
|
||||
{
|
||||
NodeId = new ExpandedNodeId("ns=2;s=Tag"),
|
||||
NodeId = ExpandedNodeId.Parse("ns=2;s=Tag"),
|
||||
DisplayName = new LocalizedText("Tag"),
|
||||
NodeClass = NodeClass.Variable
|
||||
}
|
||||
@@ -437,7 +510,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
[
|
||||
new ReferenceDescription
|
||||
{
|
||||
NodeId = new ExpandedNodeId("ns=2;s=A"),
|
||||
NodeId = ExpandedNodeId.Parse("ns=2;s=A"),
|
||||
DisplayName = new LocalizedText("A"),
|
||||
NodeClass = NodeClass.Variable
|
||||
}
|
||||
@@ -447,7 +520,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
[
|
||||
new ReferenceDescription
|
||||
{
|
||||
NodeId = new ExpandedNodeId("ns=2;s=B"),
|
||||
NodeId = ExpandedNodeId.Parse("ns=2;s=B"),
|
||||
DisplayName = new LocalizedText("B"),
|
||||
NodeClass = NodeClass.Variable
|
||||
}
|
||||
@@ -484,7 +557,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"), 500, TestContext.Current.CancellationToken);
|
||||
await _service.SubscribeAsync(NodeId.Parse("ns=2;s=MyNode"), 500, TestContext.Current.CancellationToken);
|
||||
|
||||
session.CreatedSubscriptions.Count.ShouldBe(1);
|
||||
session.CreatedSubscriptions[0].AddDataChangeCount.ShouldBe(1);
|
||||
@@ -500,8 +573,8 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"), ct: TestContext.Current.CancellationToken);
|
||||
await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"), ct: TestContext.Current.CancellationToken); // duplicate
|
||||
await _service.SubscribeAsync(NodeId.Parse("ns=2;s=MyNode"), ct: TestContext.Current.CancellationToken);
|
||||
await _service.SubscribeAsync(NodeId.Parse("ns=2;s=MyNode"), ct: TestContext.Current.CancellationToken); // duplicate
|
||||
|
||||
session.CreatedSubscriptions[0].AddDataChangeCount.ShouldBe(1);
|
||||
}
|
||||
@@ -520,7 +593,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
DataChangedEventArgs? received = null;
|
||||
_service.DataChanged += (_, e) => received = e;
|
||||
|
||||
await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"), 500, TestContext.Current.CancellationToken);
|
||||
await _service.SubscribeAsync(NodeId.Parse("ns=2;s=MyNode"), 500, TestContext.Current.CancellationToken);
|
||||
|
||||
// Simulate data change
|
||||
var handle = fakeSub.ActiveHandles.First();
|
||||
@@ -528,7 +601,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
|
||||
received.ShouldNotBeNull();
|
||||
received!.NodeId.ShouldBe("ns=2;s=MyNode");
|
||||
received.Value.Value.ShouldBe(99);
|
||||
received.Value.WrappedValue.ShouldBe(Variant.From(99));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -542,8 +615,8 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"), ct: TestContext.Current.CancellationToken);
|
||||
await _service.UnsubscribeAsync(new NodeId("ns=2;s=MyNode"), TestContext.Current.CancellationToken);
|
||||
await _service.SubscribeAsync(NodeId.Parse("ns=2;s=MyNode"), ct: TestContext.Current.CancellationToken);
|
||||
await _service.UnsubscribeAsync(NodeId.Parse("ns=2;s=MyNode"), TestContext.Current.CancellationToken);
|
||||
|
||||
fakeSub.RemoveCount.ShouldBe(1);
|
||||
}
|
||||
@@ -558,7 +631,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
await _service.UnsubscribeAsync(new NodeId("ns=2;s=NotSubscribed"), TestContext.Current.CancellationToken);
|
||||
await _service.UnsubscribeAsync(NodeId.Parse("ns=2;s=NotSubscribed"), TestContext.Current.CancellationToken);
|
||||
// Should not throw
|
||||
}
|
||||
|
||||
@@ -569,7 +642,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
public async Task SubscribeAsync_WhenDisconnected_Throws()
|
||||
{
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
_service.SubscribeAsync(new NodeId("ns=2;s=MyNode")));
|
||||
_service.SubscribeAsync(NodeId.Parse("ns=2;s=MyNode")));
|
||||
}
|
||||
|
||||
// --- Alarm subscription tests ---
|
||||
@@ -742,7 +815,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await _service.HistoryReadRawAsync(new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, ct: TestContext.Current.CancellationToken);
|
||||
var results = await _service.HistoryReadRawAsync(NodeId.Parse("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, ct: TestContext.Current.CancellationToken);
|
||||
|
||||
results.Count.ShouldBe(2);
|
||||
session.HistoryReadRawCount.ShouldBe(1);
|
||||
@@ -755,7 +828,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
public async Task HistoryReadRawAsync_WhenDisconnected_Throws()
|
||||
{
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
_service.HistoryReadRawAsync(new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow));
|
||||
_service.HistoryReadRawAsync(NodeId.Parse("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -769,7 +842,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
await Should.ThrowAsync<ServiceResultException>(() =>
|
||||
_service.HistoryReadRawAsync(new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow));
|
||||
_service.HistoryReadRawAsync(NodeId.Parse("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -786,7 +859,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await _service.HistoryReadAggregateAsync(new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, AggregateType.Average, ct: TestContext.Current.CancellationToken);
|
||||
var results = await _service.HistoryReadAggregateAsync(NodeId.Parse("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, AggregateType.Average, ct: TestContext.Current.CancellationToken);
|
||||
|
||||
results.Count.ShouldBe(1);
|
||||
session.HistoryReadAggregateCount.ShouldBe(1);
|
||||
@@ -800,7 +873,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
{
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
_service.HistoryReadAggregateAsync(
|
||||
new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow,
|
||||
NodeId.Parse("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow,
|
||||
AggregateType.Average));
|
||||
}
|
||||
|
||||
@@ -816,7 +889,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
|
||||
await Should.ThrowAsync<ServiceResultException>(() =>
|
||||
_service.HistoryReadAggregateAsync(
|
||||
new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow,
|
||||
NodeId.Parse("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow,
|
||||
AggregateType.Average));
|
||||
}
|
||||
|
||||
@@ -840,7 +913,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
return new DataValue(new Variant(["urn:server1", "urn:server2"]), StatusCodes.Good);
|
||||
if (nodeId == VariableIds.Server_ServerArray)
|
||||
return new DataValue(new Variant(["urn:server1"]), StatusCodes.Good);
|
||||
return new DataValue(StatusCodes.BadNodeIdUnknown);
|
||||
return DataValue.FromStatusCode(StatusCodes.BadNodeIdUnknown);
|
||||
}
|
||||
};
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
@@ -933,9 +1006,9 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
ReadResponseFunc = nodeId =>
|
||||
{
|
||||
if (nodeId == VariableIds.Server_ServerRedundancy_RedundancySupport)
|
||||
return new DataValue(StatusCodes.BadNotReadable); // bad status, null Value
|
||||
return DataValue.FromStatusCode(StatusCodes.BadNotReadable); // bad status, null Value
|
||||
if (nodeId == VariableIds.Server_ServiceLevel)
|
||||
return new DataValue(StatusCodes.BadNotReadable);
|
||||
return DataValue.FromStatusCode(StatusCodes.BadNotReadable);
|
||||
throw new ServiceResultException(StatusCodes.BadNodeIdUnknown);
|
||||
}
|
||||
};
|
||||
@@ -1046,7 +1119,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
var result = await _service.AcknowledgeAlarmAsync("ns=2;s=Cond", new byte[] { 1, 2 }, "acked", TestContext.Current.CancellationToken);
|
||||
|
||||
StatusCode.IsBad(result).ShouldBeTrue();
|
||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled);
|
||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled.Code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1105,7 +1178,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
var result = await _service.ConfirmAlarmAsync("ns=2;s=Cond", new byte[] { 1, 2 }, "confirmed", TestContext.Current.CancellationToken);
|
||||
|
||||
StatusCode.IsBad(result).ShouldBeTrue();
|
||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled);
|
||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled.Code);
|
||||
}
|
||||
|
||||
// --- ShelveAlarm tests ---
|
||||
@@ -1186,7 +1259,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
var result = await _service.ShelveAlarmAsync("ns=2;s=Cond", ShelveKind.OneShot, ct: TestContext.Current.CancellationToken);
|
||||
|
||||
StatusCode.IsBad(result).ShouldBeTrue();
|
||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyShelved);
|
||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyShelved.Code);
|
||||
}
|
||||
|
||||
// --- Enable / Disable tests (H4 client path) ---
|
||||
@@ -1249,7 +1322,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
var result = await _service.EnableAsync("ns=2;s=Cond", TestContext.Current.CancellationToken);
|
||||
|
||||
StatusCode.IsBad(result).ShouldBeTrue();
|
||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled);
|
||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled.Code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1270,7 +1343,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
var result = await _service.DisableAsync("ns=2;s=Cond", TestContext.Current.CancellationToken);
|
||||
|
||||
StatusCode.IsBad(result).ShouldBeTrue();
|
||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyDisabled);
|
||||
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyDisabled.Code);
|
||||
}
|
||||
|
||||
// --- Missing standard alarm fields (#598: the Galaxy-era supplemental reads are gone) ---
|
||||
@@ -1484,7 +1557,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
|
||||
var gate = new TaskCompletionSource();
|
||||
session1.CreateSubscriptionGate = gate;
|
||||
var subscribe = _service.SubscribeAsync(new NodeId("ns=2;s=Racer"), 1000, TestContext.Current.CancellationToken);
|
||||
var subscribe = _service.SubscribeAsync(NodeId.Parse("ns=2;s=Racer"), 1000, TestContext.Current.CancellationToken);
|
||||
await session1.CreateSubscriptionEntered.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
|
||||
session1.SimulateKeepAlive(false);
|
||||
@@ -1499,7 +1572,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
replayed.AddDataChangeCount.ShouldBe(1);
|
||||
|
||||
// And it is the live subscription: unsubscribing removes it from session2's subscription, not session1's.
|
||||
await _service.UnsubscribeAsync(new NodeId("ns=2;s=Racer"), TestContext.Current.CancellationToken);
|
||||
await _service.UnsubscribeAsync(NodeId.Parse("ns=2;s=Racer"), TestContext.Current.CancellationToken);
|
||||
replayed.RemoveCount.ShouldBe(1);
|
||||
replayed.ActiveHandles.ShouldBeEmpty();
|
||||
}
|
||||
@@ -1548,7 +1621,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
var tasks = new List<Task>();
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
var nodeId = new NodeId($"ns=2;s=Node{i}");
|
||||
var nodeId = NodeId.Parse($"ns=2;s=Node{i}");
|
||||
tasks.Add(Task.Run(async () =>
|
||||
{
|
||||
await _service.SubscribeAsync(nodeId);
|
||||
@@ -1560,6 +1633,55 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
await Should.NotThrowAsync(() => Task.WhenAll(tasks));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueuedCallbacks_AfterDisposal_AreIgnored()
|
||||
{
|
||||
var session = new FakeSessionAdapter();
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
await _service.SubscribeAsync(new NodeId("tag", 2), ct: TestContext.Current.CancellationToken);
|
||||
await _service.SubscribeAlarmsAsync(ct: TestContext.Current.CancellationToken);
|
||||
var data = session.CreatedSubscriptions[0];
|
||||
var alarm = session.CreatedSubscriptions[1];
|
||||
var queuedData = data.CaptureDataCallback(data.ActiveHandles.Single());
|
||||
var queuedAlarm = alarm.CaptureEventCallback(alarm.ActiveHandles.Single());
|
||||
var dataCount = 0;
|
||||
var alarmCount = 0;
|
||||
_service.DataChanged += (_, _) => dataCount++;
|
||||
_service.AlarmEvent += (_, _) => alarmCount++;
|
||||
queuedData("ns=2;s=tag", new DataValue(Variant.From(1)));
|
||||
queuedAlarm(new EventFieldList { EventFields = [Variant.From(ByteString.Empty)] });
|
||||
dataCount.ShouldBe(1);
|
||||
alarmCount.ShouldBe(1);
|
||||
_service.Dispose();
|
||||
queuedData("ns=2;s=tag", new DataValue(Variant.From(2)));
|
||||
queuedAlarm(new EventFieldList { EventFields = [Variant.From(ByteString.Empty)] });
|
||||
dataCount.ShouldBe(1);
|
||||
alarmCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueuedCallbacks_FromPreviousSession_AreIgnoredAfterFailover()
|
||||
{
|
||||
var first = new FakeSessionAdapter();
|
||||
var second = new FakeSessionAdapter();
|
||||
_sessionFactory.EnqueueSession(first);
|
||||
_sessionFactory.EnqueueSession(second);
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
await _service.SubscribeAsync(new NodeId("tag", 2), ct: TestContext.Current.CancellationToken);
|
||||
var firstSubscription = first.CreatedSubscriptions.Single();
|
||||
var queued = firstSubscription.CaptureDataCallback(firstSubscription.ActiveHandles.Single());
|
||||
var received = new List<DataValue>();
|
||||
_service.DataChanged += (_, e) => received.Add(e.Value);
|
||||
first.SimulateKeepAlive(false);
|
||||
await _service.LastFailover!.WaitAsync(TestContext.Current.CancellationToken);
|
||||
queued("ns=2;s=tag", new DataValue(Variant.From(1)));
|
||||
received.ShouldBeEmpty();
|
||||
var current = second.CreatedSubscriptions.Single();
|
||||
current.SimulateDataChange(current.ActiveHandles.Single(), new DataValue(Variant.From(2)));
|
||||
received.ShouldHaveSingleItem().WrappedValue.ShouldBe(Variant.From(2));
|
||||
}
|
||||
|
||||
// --- Dispose tests ---
|
||||
|
||||
/// <summary>
|
||||
@@ -1598,7 +1720,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
await Should.ThrowAsync<ObjectDisposedException>(() =>
|
||||
_service.ConnectAsync(ValidSettings()));
|
||||
await Should.ThrowAsync<ObjectDisposedException>(() =>
|
||||
_service.ReadValueAsync(new NodeId("ns=2;s=X")));
|
||||
_service.ReadValueAsync(NodeId.Parse("ns=2;s=X")));
|
||||
}
|
||||
|
||||
// --- Factory tests ---
|
||||
@@ -1643,7 +1765,7 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
// Subscribe data (uses dataFakeSub from session.NextSubscription).
|
||||
await _service.SubscribeAsync(new NodeId("ns=2;s=Node1"), ct: TestContext.Current.CancellationToken);
|
||||
await _service.SubscribeAsync(NodeId.Parse("ns=2;s=Node1"), ct: TestContext.Current.CancellationToken);
|
||||
|
||||
// Now set NextSubscription on the session so SubscribeAlarmsAsync gets the alarm fake.
|
||||
session.NextSubscription = alarmFakeSub;
|
||||
|
||||
@@ -191,7 +191,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
|
||||
LastBrowseParentNodeId = parentNodeId;
|
||||
if (BrowseException != null) throw BrowseException;
|
||||
|
||||
if (parentNodeId != null && BrowseResultsByParent.TryGetValue(parentNodeId.ToString(), out var perParent))
|
||||
if (parentNodeId != null && BrowseResultsByParent.TryGetValue(parentNodeId.Value.ToString(), out var perParent))
|
||||
return Task.FromResult(perParent);
|
||||
|
||||
return Task.FromResult(BrowseResults);
|
||||
|
||||
@@ -23,6 +23,26 @@ public class ReadWriteViewModelTests
|
||||
_vm = new ReadWriteViewModel(_service, dispatcher);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadCommand_FormatsSdkArraysByteStringsAndEmptyText()
|
||||
{
|
||||
_vm.IsConnected = true;
|
||||
_vm.SelectedNodeId = "ns=2;s=SomeNode";
|
||||
foreach (var (value, expected) in new (Variant Value, string Expected)[]
|
||||
{
|
||||
(Variant.From(new int[] { 1, 2 }.ToArrayOf()), "[1,2]"),
|
||||
(Variant.From(ArrayOf<int>.Empty), "[]"),
|
||||
(Variant.From(ByteString.From(new byte[] { 3, 4 })), "[3,4]"),
|
||||
(Variant.From(LocalizedText.Null), ""),
|
||||
(Variant.Null, "(null)")
|
||||
})
|
||||
{
|
||||
_service.ReadResult = new DataValue(value, StatusCodes.Good);
|
||||
await _vm.ReadCommand.ExecuteAsync(null);
|
||||
_vm.CurrentValue.ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the read command cannot execute when disconnected.</summary>
|
||||
[Fact]
|
||||
public void ReadCommand_CannotExecute_WhenDisconnected()
|
||||
|
||||
+13
-18
@@ -847,7 +847,7 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
private static async Task CallIgnoringStatusAsync(
|
||||
ISession session, NodeId objectId, NodeId methodId, CancellationToken ct, params object[] args)
|
||||
{
|
||||
try { await session.CallAsync(objectId, methodId, ct, args); }
|
||||
try { await session.CallAsync(objectId, methodId, ct, args.Select(SdkTestValues.FromClr).ToArray()); }
|
||||
catch (ServiceResultException) { /* the outcome is not under test */ }
|
||||
}
|
||||
|
||||
@@ -905,9 +905,9 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
private static MonitoredItem AddEventItem(Subscription subscription, NodeId source)
|
||||
{
|
||||
var filter = new EventFilter();
|
||||
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventId);
|
||||
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
|
||||
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventType);
|
||||
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.EventId));
|
||||
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.Message));
|
||||
filter.AddSelectClause(new NodeId(ObjectTypes.BaseEventType), QualifiedName.From(BrowseNames.EventType));
|
||||
|
||||
var item = new MonitoredItem(subscription.DefaultItem)
|
||||
{
|
||||
@@ -940,21 +940,21 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
private readonly Dictionary<(uint Handle, string Message), byte[]> _eventIds = [];
|
||||
private readonly HashSet<(uint Handle, NodeId Type)> _types = [];
|
||||
|
||||
public void OnEvents(Subscription subscription, EventNotificationList notification, IList<string> stringTable)
|
||||
public void OnEvents(Subscription subscription, EventNotificationList notification, ArrayOf<string> stringTable)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
foreach (var e in notification.Events)
|
||||
{
|
||||
if (e.EventFields.Count > MessageFieldIndex
|
||||
&& e.EventFields[MessageFieldIndex].Value is LocalizedText { Text: { } text })
|
||||
&& e.EventFields[MessageFieldIndex].ClrValue()! is LocalizedText { Text: { } text })
|
||||
{
|
||||
_seen.Add((e.ClientHandle, text));
|
||||
if (e.EventFields[0].Value is byte[] eventId) _eventIds[(e.ClientHandle, text)] = eventId;
|
||||
if (e.EventFields[0].ClrValue()! is byte[] eventId) _eventIds[(e.ClientHandle, text)] = eventId;
|
||||
}
|
||||
|
||||
if (e.EventFields.Count > EventTypeFieldIndex
|
||||
&& e.EventFields[EventTypeFieldIndex].Value is NodeId type)
|
||||
&& e.EventFields[EventTypeFieldIndex].ClrValue()! is NodeId type)
|
||||
{
|
||||
_types.Add((e.ClientHandle, type));
|
||||
}
|
||||
@@ -1115,7 +1115,7 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
|
||||
public async Task<ISession> ConnectAsync(bool anonymous, CancellationToken ct)
|
||||
{
|
||||
var appConfig = new ApplicationConfiguration
|
||||
var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
|
||||
{
|
||||
ApplicationName = "OtOpcUa.AclEventClient",
|
||||
ApplicationUri = $"urn:OtOpcUa.AclEventClient.{Guid.NewGuid():N}",
|
||||
@@ -1124,29 +1124,24 @@ public sealed class AclEventSubscriptionEnforcementTests
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
||||
};
|
||||
await appConfig.ValidateAsync(ApplicationType.Client, ct);
|
||||
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
|
||||
var endpoint = await CoreClientUtils.SelectEndpointAsync(
|
||||
appConfig, $"opc.tcp://127.0.0.1:{_port}/OtOpcUa", false, DefaultTelemetry.Create(_ => { }), ct);
|
||||
|
||||
var identity = anonymous
|
||||
? new UserIdentity(new AnonymousIdentityToken())
|
||||
: new UserIdentity(new UserNameIdentityToken
|
||||
{
|
||||
UserName = "apikey",
|
||||
// 1.5.378 carries the UserName token password as raw bytes; the host decodes UTF-8.
|
||||
DecryptedPassword = Encoding.UTF8.GetBytes($"otua_{KeyId}_secret"),
|
||||
});
|
||||
: new UserIdentity("apikey", Encoding.UTF8.GetBytes($"otua_{KeyId}_secret"));
|
||||
|
||||
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
|
||||
appConfig,
|
||||
new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(appConfig)),
|
||||
new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("Endpoint discovery returned no endpoint."), EndpointConfiguration.Create(appConfig)),
|
||||
updateBeforeConnect: false,
|
||||
checkDomain: false,
|
||||
sessionName: "AclEventSubscriptionEnforcementTests",
|
||||
sessionTimeout: 60_000,
|
||||
identity: identity,
|
||||
preferredLocales: null,
|
||||
preferredLocales: default,
|
||||
ct: ct);
|
||||
}
|
||||
|
||||
|
||||
+9
-14
@@ -63,7 +63,7 @@ public sealed class AclKeyExpiryEnforcementTests
|
||||
var (subscription, received) = await rig.SubscribeAsync(session, ct);
|
||||
|
||||
// Baseline: the key-carrying session's feed is live.
|
||||
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 1f)), TimeSpan.FromSeconds(10));
|
||||
await WaitUntilAsync(() => received.Any(v => Equals(v.ClrValue()!, 1f)), TimeSpan.FromSeconds(10));
|
||||
received.Clear();
|
||||
|
||||
// Only the clock moves. No deployment, no new generation, no grant change.
|
||||
@@ -78,7 +78,7 @@ public sealed class AclKeyExpiryEnforcementTests
|
||||
received.Clear();
|
||||
rig.Write(2);
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), ct);
|
||||
received.ShouldNotContain(v => Equals(v.Value, 2f));
|
||||
received.ShouldNotContain(v => Equals(v.ClrValue()!, 2f));
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
@@ -106,14 +106,14 @@ public sealed class AclKeyExpiryEnforcementTests
|
||||
|
||||
using var session = await rig.ConnectAsync(ct);
|
||||
var (subscription, received) = await rig.SubscribeAsync(session, ct);
|
||||
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 1f)), TimeSpan.FromSeconds(10));
|
||||
await WaitUntilAsync(() => received.Any(v => Equals(v.ClrValue()!, 1f)), TimeSpan.FromSeconds(10));
|
||||
|
||||
received.Clear();
|
||||
rig.Advance(TimeSpan.FromMinutes(20));
|
||||
rig.TickRevalidation();
|
||||
|
||||
rig.Write(2);
|
||||
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 2f)), TimeSpan.FromSeconds(10));
|
||||
await WaitUntilAsync(() => received.Any(v => Equals(v.ClrValue()!, 2f)), TimeSpan.FromSeconds(10));
|
||||
received.ShouldNotContain(v => v.StatusCode.Code == StatusCodes.BadUserAccessDenied);
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
@@ -282,7 +282,7 @@ public sealed class AclKeyExpiryEnforcementTests
|
||||
/// <returns>The connected session.</returns>
|
||||
public async Task<ISession> ConnectAsync(CancellationToken ct)
|
||||
{
|
||||
var appConfig = new ApplicationConfiguration
|
||||
var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
|
||||
{
|
||||
ApplicationName = "OtOpcUa.AclKeyClient",
|
||||
ApplicationUri = $"urn:OtOpcUa.AclKeyClient.{Guid.NewGuid():N}",
|
||||
@@ -291,25 +291,20 @@ public sealed class AclKeyExpiryEnforcementTests
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
||||
};
|
||||
await appConfig.ValidateAsync(ApplicationType.Client, ct);
|
||||
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
|
||||
var endpoint = await CoreClientUtils.SelectEndpointAsync(
|
||||
appConfig, $"opc.tcp://127.0.0.1:{_port}/OtOpcUa", false, DefaultTelemetry.Create(_ => { }), ct);
|
||||
|
||||
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
|
||||
appConfig,
|
||||
new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(appConfig)),
|
||||
new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("Endpoint discovery returned no endpoint."), EndpointConfiguration.Create(appConfig)),
|
||||
updateBeforeConnect: false,
|
||||
checkDomain: false,
|
||||
sessionName: "AclKeyExpiryEnforcementTests",
|
||||
sessionTimeout: 60_000,
|
||||
identity: new UserIdentity(new UserNameIdentityToken
|
||||
{
|
||||
UserName = "apikey",
|
||||
// 1.5.378 carries the UserName token password as raw bytes; the host decodes UTF-8.
|
||||
DecryptedPassword = Encoding.UTF8.GetBytes($"otua_{KeyId}_secret"),
|
||||
}),
|
||||
preferredLocales: null,
|
||||
identity: new UserIdentity("apikey", Encoding.UTF8.GetBytes($"otua_{KeyId}_secret")),
|
||||
preferredLocales: default,
|
||||
ct: ct);
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -183,13 +183,13 @@ public sealed class AclServerObjectModelChangeTests
|
||||
return (session, events, item, subscription);
|
||||
}
|
||||
|
||||
private static bool NamesEqA(VariantCollection e) => Names(e, EqA);
|
||||
private static bool NamesEqA(ArrayOf<Variant> e) => Names(e, EqA);
|
||||
|
||||
/// <summary>Whether a model-change event's <c>Changes</c> name the node whose identifier is <paramref name="id"/>.</summary>
|
||||
private static bool Names(VariantCollection e, string id) =>
|
||||
e[RecordedEvents.ChangesField].Value is ExtensionObject[] changes
|
||||
&& changes.Any(c => c.Body is ModelChangeStructureDataType { Affected: { } affected }
|
||||
&& affected.Identifier as string == id);
|
||||
private static bool Names(ArrayOf<Variant> e, string id) =>
|
||||
e[RecordedEvents.ChangesField].ClrValue()! is ExtensionObject[] changes
|
||||
&& changes.Any(c => ExtensionObject.ToEncodeable(c) is ModelChangeStructureDataType { Affected: { } affected }
|
||||
&& affected.IdentifierAsString == id);
|
||||
|
||||
/// <summary>Every device write fails, so the node manager reverts and audits it.</summary>
|
||||
private sealed class RejectingGateway : IOpcUaNodeWriteGateway
|
||||
|
||||
+14
-14
@@ -63,7 +63,7 @@ public sealed class AclSubscriptionEnforcementTests
|
||||
var (subscription, received) = await rig.SubscribeAsync(session, ct);
|
||||
|
||||
// Baseline: the feed is live.
|
||||
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 1f)), TimeSpan.FromSeconds(10));
|
||||
await WaitUntilAsync(() => received.Any(v => Equals(v.ClrValue()!, 1f)), TimeSpan.FromSeconds(10));
|
||||
|
||||
received.Clear();
|
||||
|
||||
@@ -79,7 +79,7 @@ public sealed class AclSubscriptionEnforcementTests
|
||||
received.Clear();
|
||||
rig.Write(2);
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), ct);
|
||||
received.ShouldNotContain(v => Equals(v.Value, 2f));
|
||||
received.ShouldNotContain(v => Equals(v.ClrValue()!, 2f));
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
@@ -106,13 +106,13 @@ public sealed class AclSubscriptionEnforcementTests
|
||||
|
||||
using var session = await rig.ConnectAsync(ct);
|
||||
var (subscription, received) = await rig.SubscribeAsync(session, ct);
|
||||
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 1f)), TimeSpan.FromSeconds(10));
|
||||
await WaitUntilAsync(() => received.Any(v => Equals(v.ClrValue()!, 1f)), TimeSpan.FromSeconds(10));
|
||||
|
||||
received.Clear();
|
||||
state.Apply(Cluster, "rev-2", [], [grant], NoScopes);
|
||||
|
||||
rig.Write(2);
|
||||
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 2f)), TimeSpan.FromSeconds(10));
|
||||
await WaitUntilAsync(() => received.Any(v => Equals(v.ClrValue()!, 2f)), TimeSpan.FromSeconds(10));
|
||||
received.ShouldNotContain(v => v.StatusCode.Code == StatusCodes.BadUserAccessDenied);
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
@@ -144,7 +144,7 @@ public sealed class AclSubscriptionEnforcementTests
|
||||
|
||||
using var session = await rig.ConnectAsync(ct);
|
||||
var (subscription, received) = await rig.SubscribeAsync(session, ct);
|
||||
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 1f)), TimeSpan.FromSeconds(10));
|
||||
await WaitUntilAsync(() => received.Any(v => Equals(v.ClrValue()!, 1f)), TimeSpan.FromSeconds(10));
|
||||
|
||||
state.Apply(Cluster, "rev-2", [], [], NoScopes);
|
||||
await WaitUntilAsync(
|
||||
@@ -156,7 +156,7 @@ public sealed class AclSubscriptionEnforcementTests
|
||||
state.Apply(Cluster, "rev-3", [], [grant], NoScopes);
|
||||
|
||||
rig.Write(3);
|
||||
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 3f)), TimeSpan.FromSeconds(10));
|
||||
await WaitUntilAsync(() => received.Any(v => Equals(v.ClrValue()!, 3f)), TimeSpan.FromSeconds(10));
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
@@ -181,11 +181,11 @@ public sealed class AclSubscriptionEnforcementTests
|
||||
|
||||
// The read works, proving the session is admitted and granted.
|
||||
var read = await session.ReadValueAsync(rig.TagNodeId(session), ct);
|
||||
read.StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
|
||||
read.StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
|
||||
var (subscription, _) = await rig.SubscribeAsync(session, ct, expectFailure: true);
|
||||
subscription.MonitoredItems.First().Status.Error?.StatusCode.Code
|
||||
.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
@@ -272,8 +272,8 @@ public sealed class AclSubscriptionEnforcementTests
|
||||
],
|
||||
ct: ct);
|
||||
|
||||
return response.Results[0].References
|
||||
.Select(r => r.NodeId.Identifier as string ?? string.Empty)
|
||||
return response.Results[0].References.ToList()
|
||||
.Select(r => r.NodeId.IdentifierAsString)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -403,7 +403,7 @@ public sealed class AclSubscriptionEnforcementTests
|
||||
|
||||
public async Task<ISession> ConnectAsync(CancellationToken ct)
|
||||
{
|
||||
var appConfig = new ApplicationConfiguration
|
||||
var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
|
||||
{
|
||||
ApplicationName = "OtOpcUa.AclClient",
|
||||
ApplicationUri = $"urn:OtOpcUa.AclClient.{Guid.NewGuid():N}",
|
||||
@@ -412,20 +412,20 @@ public sealed class AclSubscriptionEnforcementTests
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
||||
};
|
||||
await appConfig.ValidateAsync(ApplicationType.Client, ct);
|
||||
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
|
||||
var endpoint = await CoreClientUtils.SelectEndpointAsync(
|
||||
appConfig, $"opc.tcp://127.0.0.1:{_port}/OtOpcUa", false, DefaultTelemetry.Create(_ => { }), ct);
|
||||
|
||||
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
|
||||
appConfig,
|
||||
new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(appConfig)),
|
||||
new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("Endpoint discovery returned no endpoint."), EndpointConfiguration.Create(appConfig)),
|
||||
updateBeforeConnect: false,
|
||||
checkDomain: false,
|
||||
sessionName: "AclSubscriptionEnforcementTests",
|
||||
sessionTimeout: 60_000,
|
||||
identity: new UserIdentity(new AnonymousIdentityToken()),
|
||||
preferredLocales: null,
|
||||
preferredLocales: default,
|
||||
ct: ct);
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -185,17 +185,17 @@ public sealed class AdvertisedAggregateFunctionsWireTests
|
||||
private static async Task<List<string>> AdvertisedAsync(ISession session, NodeId folder, CancellationToken ct)
|
||||
{
|
||||
var refs = await BrowseAsync(session, folder, BrowseDirection.Forward, ct);
|
||||
return refs.Select(r => r.BrowseName.Name).OrderBy(n => n, StringComparer.Ordinal).ToList();
|
||||
return refs.ToList().Select(r => r.BrowseName.Name ?? throw new InvalidOperationException("Aggregate has no browse name.")).OrderBy(n => n, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
/// <summary>The folders an aggregate-function node is organized under (inverse Organizes).</summary>
|
||||
private static async Task<List<NodeId>> ParentsAsync(ISession session, NodeId aggregate, CancellationToken ct)
|
||||
{
|
||||
var refs = await BrowseAsync(session, aggregate, BrowseDirection.Inverse, ct);
|
||||
return refs.Select(r => ExpandedNodeId.ToNodeId(r.NodeId, session.NamespaceUris)).ToList();
|
||||
return refs.ToList().Select(r => ExpandedNodeId.ToNodeId(r.NodeId, session.NamespaceUris)).ToList();
|
||||
}
|
||||
|
||||
private static async Task<ReferenceDescriptionCollection> BrowseAsync(
|
||||
private static async Task<ArrayOf<ReferenceDescription>> BrowseAsync(
|
||||
ISession session, NodeId node, BrowseDirection direction, CancellationToken ct)
|
||||
{
|
||||
var response = await session.BrowseAsync(
|
||||
|
||||
+8
-14
@@ -18,10 +18,8 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
/// and the per-client API-key miss budget therefore applies to a real client.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The SDK exposes no client address on any public surface; <see cref="SecureChannelRemoteEndpoint"/>
|
||||
/// reads it from the TCP listener by reflection and degrades to null when that path breaks. These tests are
|
||||
/// what turn an SDK upgrade that breaks it into a failure instead of a silent fall-back to process-wide
|
||||
/// accounting.
|
||||
/// SDK 2 supplies observed PeerAddress during activation. This test verifies that public evidence
|
||||
/// reaches the authenticator and its per-IP miss budget; no private channel lookup is involved.
|
||||
/// </remarks>
|
||||
public sealed class ApiKeyMissBudgetPerEndpointTests
|
||||
{
|
||||
@@ -36,7 +34,7 @@ public sealed class ApiKeyMissBudgetPerEndpointTests
|
||||
|
||||
recorder.Seen.ShouldNotBeNull("the server must resolve the activating client's endpoint");
|
||||
IPAddress.IsLoopback(recorder.Seen!.Address).ShouldBeTrue($"connected from loopback, got {recorder.Seen}");
|
||||
recorder.Seen.Port.ShouldBeGreaterThan(0);
|
||||
recorder.Seen.Port.ShouldBe(0, "the public SDK evidence contains an IP address, not a source port");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -108,7 +106,7 @@ public sealed class ApiKeyMissBudgetPerEndpointTests
|
||||
|
||||
public async Task<ISession> ConnectAsync(string credential, CancellationToken ct)
|
||||
{
|
||||
var appConfig = new ApplicationConfiguration
|
||||
var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
|
||||
{
|
||||
ApplicationName = "OtOpcUa.MissBudgetClient",
|
||||
ApplicationUri = $"urn:OtOpcUa.MissBudgetClient.{Guid.NewGuid():N}",
|
||||
@@ -117,24 +115,20 @@ public sealed class ApiKeyMissBudgetPerEndpointTests
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
||||
};
|
||||
await appConfig.ValidateAsync(ApplicationType.Client, ct);
|
||||
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
|
||||
var endpoint = await CoreClientUtils.SelectEndpointAsync(
|
||||
appConfig, $"opc.tcp://127.0.0.1:{_port}/OtOpcUa", false, DefaultTelemetry.Create(_ => { }), ct);
|
||||
|
||||
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
|
||||
appConfig,
|
||||
new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(appConfig)),
|
||||
new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("Endpoint discovery returned no endpoint."), EndpointConfiguration.Create(appConfig)),
|
||||
updateBeforeConnect: false,
|
||||
checkDomain: false,
|
||||
sessionName: "ApiKeyMissBudgetPerEndpointTests",
|
||||
sessionTimeout: 60_000,
|
||||
identity: new UserIdentity(new UserNameIdentityToken
|
||||
{
|
||||
UserName = "apikey",
|
||||
DecryptedPassword = Encoding.UTF8.GetBytes(credential),
|
||||
}),
|
||||
preferredLocales: null,
|
||||
identity: new UserIdentity("apikey", Encoding.UTF8.GetBytes(credential)),
|
||||
preferredLocales: default,
|
||||
ct: ct);
|
||||
}
|
||||
|
||||
|
||||
@@ -144,23 +144,23 @@ public sealed class ArrayWriteWireTests
|
||||
|
||||
private static async Task<StatusCode> WriteAsync(ISession session, NodeId node, object value, CancellationToken ct, string? indexRange = null)
|
||||
{
|
||||
var response = await session.WriteAsync(null, new WriteValueCollection
|
||||
var response = await session.WriteAsync(null, new WriteValue[]
|
||||
{
|
||||
new WriteValue { NodeId = node, AttributeId = Attributes.Value, IndexRange = indexRange, Value = new DataValue(new Variant(value)) },
|
||||
new WriteValue { NodeId = node, AttributeId = Attributes.Value, IndexRange = indexRange, Value = new DataValue(SdkTestValues.FromClr(value)) },
|
||||
}, ct);
|
||||
return response.Results.ShouldHaveSingleItem();
|
||||
return response.Results.ToList().ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
private static async Task<short[]> ReadShortsAsync(ISession session, NodeId node, CancellationToken ct) =>
|
||||
(short[])(await session.ReadValueAsync(node, ct)).Value;
|
||||
(short[])(await session.ReadValueAsync(node, ct)).ClrValue()!;
|
||||
|
||||
private static async Task<byte> ReadAccessLevelAsync(ISession session, NodeId node, CancellationToken ct)
|
||||
{
|
||||
var response = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, new ReadValueIdCollection
|
||||
var response = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, new ReadValueId[]
|
||||
{
|
||||
new ReadValueId { NodeId = node, AttributeId = Attributes.UserAccessLevel },
|
||||
}, ct);
|
||||
return (byte)response.Results.ShouldHaveSingleItem().Value;
|
||||
return (byte)response.Results.ToList().ShouldHaveSingleItem().ClrValue()!;
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<Task<bool>> condition)
|
||||
@@ -218,7 +218,7 @@ public sealed class ArrayWriteWireTests
|
||||
|
||||
(await ReadAccessLevelAsync(session, ab, ct) & AccessLevels.CurrentWrite).ShouldBe((byte)0);
|
||||
(await WriteAsync(session, ab, new[] { 50, 60, 70, 80 }, ct)).ShouldBe((StatusCode)StatusCodes.BadNotWritable);
|
||||
((int[])(await session.ReadValueAsync(ab, ct)).Value).ShouldBe(new[] { 5, 6, 7, 8 });
|
||||
((int[])(await session.ReadValueAsync(ab, ct)).ClrValue()!).ShouldBe(new[] { 5, 6, 7, 8 });
|
||||
gateway.Count.ShouldBe(0);
|
||||
|
||||
// Positive control: the same session's write to the S7 array is dispatched, so the refusal is the node's.
|
||||
@@ -286,7 +286,7 @@ public sealed class ArrayWriteWireTests
|
||||
(await WriteAsync(session, s7, new[] { 1, 2, 3, 70_000 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch, "an Int32 element outside Int16");
|
||||
(await WriteAsync(session, galaxy, new uint[] { 4_000_000_000, 1 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch, "UInt32[] to an Int32 array");
|
||||
(await ReadShortsAsync(session, s7, ct)).ShouldBe(new short[] { 1, 2, 3, 4 });
|
||||
((int[])(await session.ReadValueAsync(galaxy, ct)).Value).ShouldBe(new[] { 11, 12 });
|
||||
((int[])(await session.ReadValueAsync(galaxy, ct)).ClrValue()!).ShouldBe(new[] { 11, 12 });
|
||||
gateway.Count.ShouldBe(0);
|
||||
|
||||
// Positive control: the correctly-typed array on the same Galaxy node is dispatched as-is.
|
||||
@@ -352,7 +352,7 @@ public sealed class ArrayWriteWireTests
|
||||
var scalar = Raw(session, S7ScalarRaw);
|
||||
|
||||
(await WriteAsync(session, scalar, new short[] { 9 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
|
||||
((short)(await session.ReadValueAsync(scalar, ct)).Value).ShouldBe((short)5);
|
||||
((short)(await session.ReadValueAsync(scalar, ct)).ClrValue()!).ShouldBe((short)5);
|
||||
gateway.Count.ShouldBe(0);
|
||||
|
||||
// Positive control: a scalar write to the same node is dispatched.
|
||||
|
||||
+15
-15
@@ -51,7 +51,7 @@ public sealed class ClientAlarmMethodTests
|
||||
using var client = await ConnectClientAsync(rig, ct);
|
||||
var conditionId = ConditionNodeId(observer, native: false).ToString();
|
||||
|
||||
async Task<VariantCollection> Next(int expectedCount)
|
||||
async Task<ArrayOf<Variant>> Next(int expectedCount)
|
||||
{
|
||||
await rig.DrainProjectionsAsync();
|
||||
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count >= expectedCount);
|
||||
@@ -61,27 +61,27 @@ public sealed class ClientAlarmMethodTests
|
||||
var n = 1;
|
||||
StatusCode.IsGood(await client.AcknowledgeAlarmAsync(
|
||||
conditionId, events.LatestEventId(item, "base"), "ack", ct)).ShouldBeTrue();
|
||||
(await Next(++n))[RecordedEvents.AckedField].Value.ShouldBe(true);
|
||||
(await Next(++n))[RecordedEvents.AckedField].ClrValue()!.ShouldBe(true);
|
||||
|
||||
StatusCode.IsGood(await client.ConfirmAlarmAsync(
|
||||
conditionId, events.LatestEventId(item, "base"), "confirm", ct)).ShouldBeTrue();
|
||||
(await Next(++n))[RecordedEvents.ConfirmedField].Value.ShouldBe(true);
|
||||
(await Next(++n))[RecordedEvents.ConfirmedField].ClrValue()!.ShouldBe(true);
|
||||
|
||||
StatusCode.IsGood(await client.AddCommentAsync(
|
||||
conditionId, events.LatestEventId(item, "base"), "note", ct)).ShouldBeTrue();
|
||||
((await Next(++n))[RecordedEvents.CommentField].Value as LocalizedText).ShouldNotBeNull().Text.ShouldBe("note");
|
||||
((await Next(++n))[RecordedEvents.CommentField].ClrValue()! as LocalizedText?).ShouldNotBeNull().Text.ShouldBe("note");
|
||||
|
||||
StatusCode.IsGood(await client.ShelveAlarmAsync(conditionId, ShelveKind.OneShot, 0, ct)).ShouldBeTrue();
|
||||
(await Next(++n))[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_OneShotShelved);
|
||||
(await Next(++n))[RecordedEvents.ShelvingField].ClrValue()!.ShouldBe(ObjectIds.ShelvedStateMachineType_OneShotShelved);
|
||||
|
||||
StatusCode.IsGood(await client.ShelveAlarmAsync(conditionId, ShelveKind.Unshelve, 0, ct)).ShouldBeTrue();
|
||||
(await Next(++n))[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
|
||||
(await Next(++n))[RecordedEvents.ShelvingField].ClrValue()!.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
|
||||
|
||||
StatusCode.IsGood(await client.ShelveAlarmAsync(conditionId, ShelveKind.Timed, 60, ct)).ShouldBeTrue();
|
||||
(await Next(++n))[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_TimedShelved);
|
||||
(await Next(++n))[RecordedEvents.ShelvingField].ClrValue()!.ShouldBe(ObjectIds.ShelvedStateMachineType_TimedShelved);
|
||||
|
||||
StatusCode.IsGood(await client.ShelveAlarmAsync(conditionId, ShelveKind.Unshelve, 0, ct)).ShouldBeTrue();
|
||||
(await Next(++n))[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
|
||||
(await Next(++n))[RecordedEvents.ShelvingField].ClrValue()!.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
@@ -113,23 +113,23 @@ public sealed class ClientAlarmMethodTests
|
||||
var eventId = events.LatestEventId(item, "base");
|
||||
|
||||
// Refusals first: they must not disturb the condition, so the ack below still lands as event #2.
|
||||
(await client.ConfirmAlarmAsync(conditionId, eventId, "confirm", ct)).Code.ShouldBe(StatusCodes.BadNotSupported);
|
||||
(await client.ShelveAlarmAsync(conditionId, ShelveKind.OneShot, 0, ct)).Code.ShouldBe(StatusCodes.BadNotSupported);
|
||||
(await client.ShelveAlarmAsync(conditionId, ShelveKind.Timed, 60, ct)).Code.ShouldBe(StatusCodes.BadNotSupported);
|
||||
(await client.ConfirmAlarmAsync(conditionId, eventId, "confirm", ct)).Code.ShouldBe(StatusCodes.BadNotSupported.Code);
|
||||
(await client.ShelveAlarmAsync(conditionId, ShelveKind.OneShot, 0, ct)).Code.ShouldBe(StatusCodes.BadNotSupported.Code);
|
||||
(await client.ShelveAlarmAsync(conditionId, ShelveKind.Timed, 60, ct)).Code.ShouldBe(StatusCodes.BadNotSupported.Code);
|
||||
|
||||
StatusCode.IsGood(await client.AcknowledgeAlarmAsync(conditionId, eventId, "ack", ct)).ShouldBeTrue();
|
||||
await rig.DrainProjectionsAsync();
|
||||
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count >= 2);
|
||||
var ack = events.ConditionEvents(item, "base")[1];
|
||||
ack[RecordedEvents.AckedField].Value.ShouldBe(true);
|
||||
ack[RecordedEvents.ConfirmedField].Value.ShouldBe(true);
|
||||
ack[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
|
||||
ack[RecordedEvents.AckedField].ClrValue()!.ShouldBe(true);
|
||||
ack[RecordedEvents.ConfirmedField].ClrValue()!.ShouldBe(true);
|
||||
ack[RecordedEvents.ShelvingField].ClrValue()!.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
|
||||
|
||||
StatusCode.IsGood(await client.AddCommentAsync(
|
||||
conditionId, events.LatestEventId(item, "base"), "note", ct)).ShouldBeTrue();
|
||||
await rig.DrainProjectionsAsync();
|
||||
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count >= 3);
|
||||
(events.ConditionEvents(item, "base")[2][RecordedEvents.CommentField].Value as LocalizedText).ShouldNotBeNull().Text.ShouldBe("note");
|
||||
(events.ConditionEvents(item, "base")[2][RecordedEvents.CommentField].ClrValue()! as LocalizedText?).ShouldNotBeNull().Text.ShouldBe("note");
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
|
||||
+27
-27
@@ -69,14 +69,14 @@ public sealed class ConditionChildNodeIdTests
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var condition = ConditionNodeId(session, native);
|
||||
var conditionId = (string)condition.Identifier;
|
||||
var conditionId = condition.IdentifierAsString;
|
||||
|
||||
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");
|
||||
resolved[i].IdentifierAsString.ShouldBe($"{conditionId}/{path}", $"{path} is scoped to the condition instance");
|
||||
}
|
||||
|
||||
var values = await ReadAsync(session, resolved, ct);
|
||||
@@ -84,25 +84,25 @@ public sealed class ConditionChildNodeIdTests
|
||||
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(
|
||||
values[0].ClrValue()!.ShouldBe(true, "ActiveState/Id — the instance is active");
|
||||
values[1].ClrValue()!.ShouldBe(false, "AckedState/Id — the instance is unacknowledged");
|
||||
values[2].ClrValue()!.ShouldBe(true, "EnabledState/Id");
|
||||
values[3].ClrValue()!.ShouldBe(
|
||||
rig.Nm.TryGetAlarmCondition(native ? RawAlarmPath : ScriptedAlarmId, native ? AddressSpaceRealm.Raw : AddressSpaceRealm.Uns)!
|
||||
.Severity.Value,
|
||||
.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");
|
||||
((ushort)values[3].ClrValue()!).ShouldBeGreaterThan((ushort)0);
|
||||
(values[4].ClrValue()! as LocalizedText?)?.Text.ShouldBe(expectedMessage, "Message");
|
||||
values[5].ClrValue()!.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);
|
||||
((double)values[6].ClrValue()!).ShouldBe(0d, "UnshelveTime — a native condition is never shelved");
|
||||
values[7].ClrValue()!.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);
|
||||
((double)values[6].ClrValue()!).ShouldBeInRange(50_000d, 60_000d, "UnshelveTime — the instance's timed shelve");
|
||||
values[7].ClrValue()!.ShouldBe(ObjectIds.ShelvedStateMachineType_TimedShelved);
|
||||
}
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
@@ -196,8 +196,8 @@ public sealed class ConditionChildNodeIdTests
|
||||
], 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[0].ClrValue()! as LocalizedText?)?.Text.ShouldBe("native-distinct");
|
||||
values[1].ClrValue()!.ShouldBe(true);
|
||||
values[2].StatusCode.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied, "ungranted condition's child");
|
||||
values[3].StatusCode.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied, "ungranted condition's child");
|
||||
}
|
||||
@@ -227,7 +227,7 @@ public sealed class ConditionChildNodeIdTests
|
||||
|
||||
// Positive control: readable while the condition exists.
|
||||
var before = await ReadAsync(session, [child, nested], ct);
|
||||
(before[0].Value as LocalizedText)?.Text.ShouldBe("scripted-distinct");
|
||||
(before[0].ClrValue()! as LocalizedText?)?.Text.ShouldBe("scripted-distinct");
|
||||
StatusCode.IsGood(before[1].StatusCode).ShouldBeTrue();
|
||||
|
||||
rig.Nm.RemoveAlarmConditionNode(ScriptedAlarmId, AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||
@@ -283,13 +283,13 @@ public sealed class ConditionChildNodeIdTests
|
||||
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
|
||||
events.ConditionEvents(item, "base")[1][RecordedEvents.ShelvingField].ClrValue()!
|
||||
.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
|
||||
events.ConditionEvents(item, "base")[2][RecordedEvents.ShelvingField].ClrValue()!
|
||||
.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
@@ -354,24 +354,24 @@ public sealed class ConditionChildNodeIdTests
|
||||
private static async Task<IReadOnlyList<NodeId>> TranslateAsync(
|
||||
ISession session, NodeId start, IReadOnlyList<string[]> paths, CancellationToken ct)
|
||||
{
|
||||
var browsePaths = new BrowsePathCollection();
|
||||
var browsePaths = new List<BrowsePath>();
|
||||
foreach (var path in paths)
|
||||
{
|
||||
var relative = new RelativePath();
|
||||
foreach (var name in path)
|
||||
{
|
||||
relative.Elements.Add(new RelativePathElement
|
||||
relative.Elements += 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 response = await session.TranslateBrowsePathsToNodeIdsAsync(null, browsePaths.ToArrayOf(), ct);
|
||||
var results = new List<NodeId>();
|
||||
for (var i = 0; i < response.Results.Count; i++)
|
||||
{
|
||||
@@ -385,14 +385,14 @@ public sealed class ConditionChildNodeIdTests
|
||||
|
||||
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 request = ids.Select(id => new ReadValueId { NodeId = id, AttributeId = Attributes.Value }).ToArrayOf();
|
||||
var response = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, request, ct);
|
||||
return response.Results;
|
||||
return response.Results.ToList();
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<NodeId>> BrowseChildrenAsync(ISession session, NodeId node, CancellationToken ct)
|
||||
{
|
||||
var request = new BrowseDescriptionCollection
|
||||
var request = new BrowseDescription[]
|
||||
{
|
||||
new BrowseDescription
|
||||
{
|
||||
@@ -404,7 +404,7 @@ public sealed class ConditionChildNodeIdTests
|
||||
},
|
||||
};
|
||||
var response = await session.BrowseAsync(null, null, 0, request, ct);
|
||||
return response.Results[0].References
|
||||
return response.Results[0].References.ToList()
|
||||
.Select(r => ExpandedNodeId.ToNodeId(r.NodeId, session.NamespaceUris))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
+26
-26
@@ -172,8 +172,8 @@ public sealed class ConditionMethodEventTests
|
||||
events.ConditionEvents(folderItem, "base").Count.ShouldBe(1);
|
||||
events.ConditionEvents(serverItem, "base").Count.ShouldBe(1);
|
||||
var sentinel = events.ConditionEvents(serverItem, "sentinel")[0];
|
||||
sentinel[RecordedEvents.ConfirmedField].Value.ShouldBe(true);
|
||||
sentinel[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
|
||||
sentinel[RecordedEvents.ConfirmedField].ClrValue()!.ShouldBe(true);
|
||||
sentinel[RecordedEvents.ShelvingField].ClrValue()!.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
@@ -252,9 +252,9 @@ public sealed class ConditionMethodEventTests
|
||||
|
||||
var baseEvents = events.ConditionEvents(item, "base");
|
||||
baseEvents.Count.ShouldBe(2, "the base transition plus the one ack event; the echo is no delta");
|
||||
baseEvents[1][RecordedEvents.AckedField].Value.ShouldBe(true);
|
||||
baseEvents[1][RecordedEvents.AckedField].ClrValue()!.ShouldBe(true);
|
||||
foreach (var e in baseEvents.Concat(events.ConditionEvents(item, "cleared")).Concat(events.ConditionEvents(item, "reactivated")))
|
||||
e[RecordedEvents.ConfirmedField].Value.ShouldBe(true);
|
||||
e[RecordedEvents.ConfirmedField].ClrValue()!.ShouldBe(true);
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
@@ -349,7 +349,7 @@ public sealed class ConditionMethodEventTests
|
||||
routed.ShouldNotContain("Unshelve", "the SDK timer must not route an operator-style Unshelve");
|
||||
unshelvedBy.ShouldBe("AutoUnshelve:system", "the engine's own expiry owns the unshelve");
|
||||
|
||||
var shelving = events.ConditionEvents(item, "base").Select(e => e[RecordedEvents.ShelvingField].Value).ToList();
|
||||
var shelving = events.ConditionEvents(item, "base").Select(e => e[RecordedEvents.ShelvingField].ClrValue()!).ToList();
|
||||
shelving.ShouldBe(
|
||||
[ObjectIds.ShelvedStateMachineType_Unshelved, ObjectIds.ShelvedStateMachineType_TimedShelved,
|
||||
ObjectIds.ShelvedStateMachineType_Unshelved],
|
||||
@@ -396,7 +396,7 @@ public sealed class ConditionMethodEventTests
|
||||
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
double RemainingMs(string message) =>
|
||||
(double)events.ConditionEvents(item, message)[^1][RecordedEvents.UnshelveTimeField].Value;
|
||||
(double)events.ConditionEvents(item, message)[^1][RecordedEvents.UnshelveTimeField].ClrValue()!;
|
||||
|
||||
rig.Project(false, current);
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
|
||||
@@ -473,7 +473,7 @@ public sealed class ConditionMethodEventTests
|
||||
CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct));
|
||||
refused.StatusCode.ShouldBe(StatusCodes.BadInvalidState);
|
||||
routed.ShouldBe(0, "a refused ack reaches no driver");
|
||||
rig.Nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw)!.AckedState.Id.Value
|
||||
rig.Nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw)!.AckedState!.Id!.Value
|
||||
.ShouldBeFalse("a refused ack applies nothing");
|
||||
|
||||
// Positive control: the same call on the Primary is accepted, routed and reported.
|
||||
@@ -486,7 +486,7 @@ public sealed class ConditionMethodEventTests
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
|
||||
var baseEvents = events.ConditionEvents(item, "base");
|
||||
baseEvents.Count.ShouldBe(2, "the base transition plus the Primary's one ack event; the refusal reported nothing");
|
||||
baseEvents[1][RecordedEvents.AckedField].Value.ShouldBe(true);
|
||||
baseEvents[1][RecordedEvents.AckedField].ClrValue()!.ShouldBe(true);
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
@@ -530,7 +530,7 @@ public sealed class ConditionMethodEventTests
|
||||
CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct));
|
||||
refused.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
routed.ShouldBe(0, "a refused ack reaches no driver");
|
||||
rig.Nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw)!.AckedState.Id.Value
|
||||
rig.Nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw)!.AckedState!.Id!.Value
|
||||
.ShouldBeFalse("a refused ack applies nothing");
|
||||
|
||||
// Positive control: with nothing to object to, the same call is accepted, routed and reported.
|
||||
@@ -543,7 +543,7 @@ public sealed class ConditionMethodEventTests
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
|
||||
var baseEvents = events.ConditionEvents(item, "base");
|
||||
baseEvents.Count.ShouldBe(2, "the base transition plus the accepted ack's event; the refusal reported nothing");
|
||||
baseEvents[1][RecordedEvents.AckedField].Value.ShouldBe(true);
|
||||
baseEvents[1][RecordedEvents.AckedField].ClrValue()!.ShouldBe(true);
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
@@ -620,9 +620,9 @@ public sealed class ConditionMethodEventTests
|
||||
{
|
||||
baseEvents.Count.ShouldBe(expected + 1, "a commented re-ack reports exactly one comment event");
|
||||
var commented = baseEvents[^1];
|
||||
(commented[RecordedEvents.CommentField].Value as LocalizedText)?.Text.ShouldBe(reAckComment);
|
||||
commented[RecordedEvents.AckedField].Value.ShouldBe(true);
|
||||
commented[RecordedEvents.ConfirmedField].Value.ShouldBe(true, "the commented re-ack must not unconfirm the node");
|
||||
(commented[RecordedEvents.CommentField].ClrValue()! as LocalizedText?)?.Text.ShouldBe(reAckComment);
|
||||
commented[RecordedEvents.AckedField].ClrValue()!.ShouldBe(true);
|
||||
commented[RecordedEvents.ConfirmedField].ClrValue()!.ShouldBe(true, "the commented re-ack must not unconfirm the node");
|
||||
}
|
||||
|
||||
// A native re-ack is still routed to the driver: the route is fire-and-forget, so a node showing
|
||||
@@ -632,8 +632,8 @@ public sealed class ConditionMethodEventTests
|
||||
|
||||
var sentinel = events.ConditionEvents(item, "sentinel");
|
||||
sentinel.Count.ShouldBe(1);
|
||||
sentinel[0][RecordedEvents.AckedField].Value.ShouldBe(true);
|
||||
sentinel[0][RecordedEvents.ConfirmedField].Value.ShouldBe(true, "the re-ack must not unconfirm the node");
|
||||
sentinel[0][RecordedEvents.AckedField].ClrValue()!.ShouldBe(true);
|
||||
sentinel[0][RecordedEvents.ConfirmedField].ClrValue()!.ShouldBe(true, "the re-ack must not unconfirm the node");
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
@@ -697,20 +697,20 @@ public sealed class ConditionMethodEventTests
|
||||
};
|
||||
}
|
||||
|
||||
private static void AssertNewState(string method, bool native, VariantCollection e)
|
||||
private static void AssertNewState(string method, bool native, ArrayOf<Variant> e)
|
||||
{
|
||||
// A native condition is never Unconfirmed, whatever the method (the SDK's ack would make it so).
|
||||
if (native) e[RecordedEvents.ConfirmedField].Value.ShouldBe(true);
|
||||
if (native) e[RecordedEvents.ConfirmedField].ClrValue()!.ShouldBe(true);
|
||||
switch (method)
|
||||
{
|
||||
case "Acknowledge": e[RecordedEvents.AckedField].Value.ShouldBe(true); break;
|
||||
case "Confirm": e[RecordedEvents.ConfirmedField].Value.ShouldBe(true); break;
|
||||
case "AddComment": (e[RecordedEvents.CommentField].Value as LocalizedText)?.Text.ShouldBe("note"); break;
|
||||
case "OneShotShelve": e[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_OneShotShelved); break;
|
||||
case "TimedShelve": e[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_TimedShelved); break;
|
||||
case "Unshelve": e[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved); break;
|
||||
case "Disable": e[RecordedEvents.EnabledField].Value.ShouldBe(false); break;
|
||||
case "Enable": e[RecordedEvents.EnabledField].Value.ShouldBe(true); break;
|
||||
case "Acknowledge": e[RecordedEvents.AckedField].ClrValue()!.ShouldBe(true); break;
|
||||
case "Confirm": e[RecordedEvents.ConfirmedField].ClrValue()!.ShouldBe(true); break;
|
||||
case "AddComment": (e[RecordedEvents.CommentField].ClrValue()! as LocalizedText?)?.Text.ShouldBe("note"); break;
|
||||
case "OneShotShelve": e[RecordedEvents.ShelvingField].ClrValue()!.ShouldBe(ObjectIds.ShelvedStateMachineType_OneShotShelved); break;
|
||||
case "TimedShelve": e[RecordedEvents.ShelvingField].ClrValue()!.ShouldBe(ObjectIds.ShelvedStateMachineType_TimedShelved); break;
|
||||
case "Unshelve": e[RecordedEvents.ShelvingField].ClrValue()!.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved); break;
|
||||
case "Disable": e[RecordedEvents.EnabledField].ClrValue()!.ShouldBe(false); break;
|
||||
case "Enable": e[RecordedEvents.EnabledField].ClrValue()!.ShouldBe(true); break;
|
||||
default: throw new ArgumentOutOfRangeException(nameof(method), method, null);
|
||||
}
|
||||
}
|
||||
@@ -736,6 +736,6 @@ public sealed class ConditionMethodEventTests
|
||||
"Unshelve" => (MethodIds.AlarmConditionType_ShelvingState_Unshelve, []),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(method), method, null),
|
||||
};
|
||||
await session.CallAsync(condition, methodId, ct, args);
|
||||
await session.CallAsync(condition, methodId, ct, args.Select(SdkTestValues.FromClr).ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
+9
-9
@@ -51,13 +51,13 @@ public sealed class DisabledVirtualTagWireTests
|
||||
|
||||
// Raw ReadAsync: ReadValueAsync throws on a Bad status, and a Bad status is exactly what we assert.
|
||||
var readResponse = await session.ReadAsync(null, 0, TimestampsToReturn.Both,
|
||||
new ReadValueIdCollection { new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value } }, ct);
|
||||
new ReadValueId[] { new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value } }, ct);
|
||||
var read = readResponse.Results[0];
|
||||
read.StatusCode.Code.ShouldNotBe(StatusCodes.BadNodeIdUnknown, "the disabled VirtualTag's node must exist");
|
||||
read.StatusCode.Code.ShouldBe(StatusCodes.BadOutOfService);
|
||||
read.Value.ShouldBeNull();
|
||||
read.StatusCode.Code.ShouldNotBe(StatusCodes.BadNodeIdUnknown.Code, "the disabled VirtualTag's node must exist");
|
||||
read.StatusCode.Code.ShouldBe(StatusCodes.BadOutOfService.Code);
|
||||
read.ClrValue()!.ShouldBeNull();
|
||||
|
||||
var write = await session.WriteAsync(null, new WriteValueCollection
|
||||
var write = await session.WriteAsync(null, new WriteValue[]
|
||||
{
|
||||
new WriteValue { NodeId = nodeId, AttributeId = Attributes.Value, Value = new DataValue(new Variant(1.0f)) },
|
||||
}, ct);
|
||||
@@ -100,7 +100,7 @@ public sealed class DisabledVirtualTagWireTests
|
||||
|
||||
private static async Task<ISession> OpenSessionAsync(string endpointUrl, CancellationToken ct)
|
||||
{
|
||||
var appConfig = new ApplicationConfiguration
|
||||
var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
|
||||
{
|
||||
ApplicationName = "OtOpcUa.DisabledVtClient",
|
||||
ApplicationUri = $"urn:OtOpcUa.DisabledVtClient.{Guid.NewGuid():N}",
|
||||
@@ -109,16 +109,16 @@ public sealed class DisabledVirtualTagWireTests
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
||||
};
|
||||
await appConfig.ValidateAsync(ApplicationType.Client, ct);
|
||||
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
|
||||
var endpoint = await CoreClientUtils.SelectEndpointAsync(
|
||||
appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct);
|
||||
var endpointConfiguration = EndpointConfiguration.Create(appConfig);
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("Endpoint discovery returned no endpoint."), endpointConfiguration);
|
||||
|
||||
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
|
||||
appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false,
|
||||
sessionName: "DisabledVirtualTagWireTests", sessionTimeout: 60_000,
|
||||
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct);
|
||||
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: default, ct: ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ public sealed class DualEndpointTests
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance);
|
||||
},
|
||||
() => new StandardServer(), CancellationToken.None);
|
||||
() => new OtOpcUaSdkServer(), CancellationToken.None);
|
||||
return (started.Host, boundPort);
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ public sealed class DualEndpointTests
|
||||
var clientPki = TestClientSecurity.AllocatePkiRoot();
|
||||
try
|
||||
{
|
||||
var appConfig = new ApplicationConfiguration
|
||||
var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
|
||||
{
|
||||
ApplicationName = "OtOpcUa.DualEndpointClient",
|
||||
ApplicationUri = $"urn:OtOpcUa.DualEndpointClient.{Guid.NewGuid():N}",
|
||||
@@ -91,13 +91,13 @@ public sealed class DualEndpointTests
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
||||
};
|
||||
await appConfig.ValidateAsync(ApplicationType.Client, default);
|
||||
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
|
||||
// 1.5.378: the discovery/session/read client surface moved to async.
|
||||
var endpoint = await CoreClientUtils.SelectEndpointAsync(
|
||||
appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), default);
|
||||
var endpointConfiguration = EndpointConfiguration.Create(appConfig);
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("Endpoint discovery returned no endpoint."), endpointConfiguration);
|
||||
|
||||
using var session = await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
|
||||
appConfig,
|
||||
@@ -107,11 +107,11 @@ public sealed class DualEndpointTests
|
||||
sessionName: "DualEndpointTests",
|
||||
sessionTimeout: 60_000,
|
||||
identity: new UserIdentity(new AnonymousIdentityToken()),
|
||||
preferredLocales: null,
|
||||
preferredLocales: default,
|
||||
ct: default);
|
||||
|
||||
var value = await session.ReadValueAsync(VariableIds.Server_ServerArray, default);
|
||||
return (string[])value.Value;
|
||||
return (string[])value.ClrValue()!;
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
+16
-16
@@ -84,18 +84,18 @@ public sealed class DualNamespaceAddressSpaceTests
|
||||
var unsValue = await session.ReadValueAsync(unsNodeId, ct);
|
||||
StatusCode.IsGood(rawValue.StatusCode).ShouldBeTrue("raw node reads Good");
|
||||
StatusCode.IsGood(unsValue.StatusCode).ShouldBeTrue("uns node reads Good");
|
||||
Convert.ToSingle(rawValue.Value).ShouldBe(42.5f);
|
||||
Convert.ToSingle(unsValue.Value).ShouldBe(42.5f);
|
||||
Convert.ToSingle(rawValue.ClrValue()!).ShouldBe(42.5f);
|
||||
Convert.ToSingle(unsValue.ClrValue()!).ShouldBe(42.5f);
|
||||
|
||||
// (3) the UNS Variable Organizes-references its backing raw node.
|
||||
var (_, refs) = await BrowseForwardAsync(session, unsNodeId, ReferenceTypeIds.Organizes);
|
||||
var organizesRaw = refs.Any(r =>
|
||||
var organizesRaw = refs.ToList().Any(r =>
|
||||
r.ReferenceTypeId == ReferenceTypeIds.Organizes &&
|
||||
r.NodeId.NamespaceIndex == rawNs &&
|
||||
r.NodeId.ToString().Contains(RawTagPath, StringComparison.Ordinal));
|
||||
organizesRaw.ShouldBeTrue(
|
||||
$"the UNS variable ({UnsVarPath}) must Organizes-reference its raw node ({RawTagPath}); " +
|
||||
$"forward Organizes refs = [{string.Join(", ", refs.Select(r => r.NodeId))}]");
|
||||
$"forward Organizes refs = [{string.Join(", ", refs.ToList().Select(r => r.NodeId))}]");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -129,7 +129,7 @@ public sealed class DualNamespaceAddressSpaceTests
|
||||
var rawValue = await session.ReadValueAsync(new NodeId(RawTagPath, rawNs), ct);
|
||||
var unsValue = await session.ReadValueAsync(new NodeId(UnsVarPath, unsNs), ct);
|
||||
|
||||
Convert.ToSingle(unsValue.Value).ShouldBe(Convert.ToSingle(rawValue.Value));
|
||||
Convert.ToSingle(unsValue.ClrValue()!).ShouldBe(Convert.ToSingle(rawValue.ClrValue()!));
|
||||
unsValue.StatusCode.ShouldBe(rawValue.StatusCode);
|
||||
unsValue.SourceTimestamp.ShouldBe(rawValue.SourceTimestamp);
|
||||
unsValue.SourceTimestamp.ShouldBe(ts);
|
||||
@@ -219,14 +219,14 @@ public sealed class DualNamespaceAddressSpaceTests
|
||||
sink.AddReference(UnsVarPath, AddressSpaceRealm.Uns, RawTagPath, AddressSpaceRealm.Raw, "Organizes");
|
||||
}
|
||||
|
||||
private static async Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)>
|
||||
private static async Task<(byte[]? ContinuationPoint, ArrayOf<ReferenceDescription> References)>
|
||||
BrowseForwardAsync(ISession session, NodeId node, NodeId referenceType)
|
||||
{
|
||||
// Match the SDK overload the codebase uses (DefaultSessionAdapter.BrowseAsync) — no trailing ct.
|
||||
var (_, cp, refs) = await session.BrowseAsync(
|
||||
null, null, node, 0u, BrowseDirection.Forward, referenceType,
|
||||
includeSubtypes: true, nodeClassMask: 0u);
|
||||
return (cp, refs ?? new ReferenceDescriptionCollection());
|
||||
return (cp.IsEmpty ? null : cp.ToArray(), refs);
|
||||
}
|
||||
|
||||
private static async Task<StatusCode> HistoryReadRawStatusAsync(ISession session, NodeId node, CancellationToken ct)
|
||||
@@ -239,22 +239,22 @@ public sealed class DualNamespaceAddressSpaceTests
|
||||
IsReadModified = false,
|
||||
ReturnBounds = false,
|
||||
};
|
||||
var nodesToRead = new HistoryReadValueIdCollection { new HistoryReadValueId { NodeId = node } };
|
||||
var nodesToRead = new HistoryReadValueId[] { new HistoryReadValueId { NodeId = node } };
|
||||
var response = await session.HistoryReadAsync(
|
||||
null, new ExtensionObject(details), TimestampsToReturn.Source, false, nodesToRead, ct);
|
||||
response.Results.ShouldNotBeNull();
|
||||
response.Results.IsNull.ShouldBeFalse();
|
||||
response.Results.Count.ShouldBe(1);
|
||||
return response.Results[0].StatusCode;
|
||||
}
|
||||
|
||||
private static async Task<StatusCode> WriteValueStatusAsync(ISession session, NodeId node, object value, CancellationToken ct)
|
||||
{
|
||||
var writeCollection = new WriteValueCollection
|
||||
var writeCollection = new WriteValue[]
|
||||
{
|
||||
new WriteValue { NodeId = node, AttributeId = Attributes.Value, Value = new DataValue(new Variant(value)) },
|
||||
new WriteValue { NodeId = node, AttributeId = Attributes.Value, Value = new DataValue(SdkTestValues.FromClr(value)) },
|
||||
};
|
||||
var response = await session.WriteAsync(null, writeCollection, ct);
|
||||
response.Results.ShouldNotBeNull();
|
||||
response.Results.IsNull.ShouldBeFalse();
|
||||
response.Results.Count.ShouldBe(1);
|
||||
return response.Results[0];
|
||||
}
|
||||
@@ -287,7 +287,7 @@ public sealed class DualNamespaceAddressSpaceTests
|
||||
|
||||
private static async Task<ISession> OpenSessionAsync(string endpointUrl, CancellationToken ct)
|
||||
{
|
||||
var appConfig = new ApplicationConfiguration
|
||||
var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
|
||||
{
|
||||
ApplicationName = "OtOpcUa.DualNamespaceClient",
|
||||
ApplicationUri = $"urn:OtOpcUa.DualNamespaceClient.{Guid.NewGuid():N}",
|
||||
@@ -296,17 +296,17 @@ public sealed class DualNamespaceAddressSpaceTests
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
||||
};
|
||||
await appConfig.ValidateAsync(ApplicationType.Client, ct);
|
||||
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
|
||||
var endpoint = await CoreClientUtils.SelectEndpointAsync(
|
||||
appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct);
|
||||
var endpointConfiguration = EndpointConfiguration.Create(appConfig);
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("Endpoint discovery returned no endpoint."), endpointConfiguration);
|
||||
|
||||
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
|
||||
appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false,
|
||||
sessionName: "DualNamespaceAddressSpaceTests", sessionTimeout: 60_000,
|
||||
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct);
|
||||
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: default, ct: ct);
|
||||
}
|
||||
|
||||
private static void SafeDelete(string dir)
|
||||
|
||||
+9
-9
@@ -94,14 +94,14 @@ public sealed class EventHistoryPagingOverTheWireTests
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
private static async Task<(List<string> Ids, byte[]? Cp)> ReadPageAsync(
|
||||
internal static async Task<(List<string> Ids, byte[]? Cp)> ReadPageAsync(
|
||||
ISession session, NodeId node, byte[]? cp, CancellationToken ct, bool reverse = false)
|
||||
{
|
||||
var filter = new EventFilter();
|
||||
filter.SelectClauses.Add(new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("EventId"))
|
||||
filter.SelectClauses += new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("EventId"))
|
||||
{
|
||||
AttributeId = Attributes.Value,
|
||||
});
|
||||
};
|
||||
var details = new ReadEventDetails
|
||||
{
|
||||
StartTime = reverse ? Epoch.AddDays(1) : Epoch.AddDays(-1),
|
||||
@@ -111,16 +111,16 @@ public sealed class EventHistoryPagingOverTheWireTests
|
||||
};
|
||||
var response = await session.HistoryReadAsync(
|
||||
null, new ExtensionObject(details), TimestampsToReturn.Both, false,
|
||||
new HistoryReadValueIdCollection { new HistoryReadValueId { NodeId = node, ContinuationPoint = cp } }, ct);
|
||||
var result = response.Results.ShouldHaveSingleItem();
|
||||
new HistoryReadValueId[] { new() { NodeId = node, ContinuationPoint = cp is null ? default : ByteString.From(cp) } }, ct);
|
||||
var result = response.Results.ToList().ShouldHaveSingleItem();
|
||||
StatusCode.IsBad(result.StatusCode).ShouldBeFalse($"HistoryRead failed: {result.StatusCode}");
|
||||
var he = (HistoryEvent)ExtensionObject.ToEncodeable(result.HistoryData);
|
||||
var ids = he.Events.Select(e => System.Text.Encoding.UTF8.GetString((byte[])e.EventFields[0].Value)).ToList();
|
||||
return (ids, result.ContinuationPoint is { Length: > 0 } c ? c : null);
|
||||
var he = ExtensionObject.ToEncodeable(result.HistoryData).ShouldBeOfType<HistoryEvent>();
|
||||
var ids = he.Events.ToList().Select(e => System.Text.Encoding.UTF8.GetString(e.EventFields[0].GetByteString().Span)).ToList();
|
||||
return (ids, result.ContinuationPoint.IsEmpty ? null : result.ContinuationPoint.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>(time, id) order, [start, end), cap + truncation flag — the SQL backends' contract.</summary>
|
||||
private sealed class SqlLikeEventSource(List<HistoricalEvent> events) : IHistorianDataSource
|
||||
internal sealed class SqlLikeEventSource(List<HistoricalEvent> events) : IHistorianDataSource
|
||||
{
|
||||
private readonly List<HistoricalEvent> _all =
|
||||
events.OrderBy(e => e.EventTimeUtc).ThenBy(e => e.EventId, StringComparer.Ordinal).ToList();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user