Compare commits

..

4 Commits

Author SHA1 Message Date
Joseph Doherty 47b66caa8d test: verify SDK 2 driver boundaries and lifecycle contracts 2026-11-10 10:21:53 -05:00
Joseph Doherty c6ce30121c fix: reject unsupported upstream matrix payloads per item 2026-11-10 10:20:36 -05:00
Joseph Doherty 62c3ba6c37 fix: preserve SDK 2 driver value and credential lifetimes 2026-11-10 10:17:03 -05:00
Joseph Doherty b583840fbc refactor: migrate OPC UA driver and browser to SDK 2.0 2026-11-10 10:08:12 -05:00
194 changed files with 2137 additions and 18178 deletions
+3 -2
View File
@@ -126,6 +126,7 @@
provider that breaks the "managed only" rule, which is why it is opt-in per instance. -->
<PackageVersion Include="System.Data.Odbc" Version="10.0.14" />
<PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer" Version="2.0.1" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="2.0.1" />
<!-- Core carries Opc.Ua.StatusCodes, the oracle StatusCodeParityTests checks the drivers'
hard-coded uint constants against. Referenced by that TEST project only — the drivers
@@ -242,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.4.0" />
<PackageVersion Include="ZB.MOM.WW.HistorianGateway.Contracts" Version="0.4.0" />
<PackageVersion Include="ZB.MOM.WW.HistorianGateway.Client" Version="0.3.0" />
<PackageVersion Include="ZB.MOM.WW.HistorianGateway.Contracts" Version="0.3.0" />
</ItemGroup>
</Project>
+1 -45
View File
@@ -22,37 +22,6 @@ 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
@@ -337,7 +306,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.4.0** exposes no RPC that writes values for more than one tag
> `ZB.MOM.WW.HistorianGateway.Client` **0.3.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
@@ -345,19 +314,6 @@ 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`
+167 -70
View File
@@ -1,88 +1,185 @@
# OPC UA Client test fixture
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.
Coverage map + gap inventory for the OPC UA Client (gateway / aggregation)
driver.
## Fixture layers
**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).
| 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. |
## What the fixture is
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.
**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 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).
**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 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).
## What it actually covers
## Running locally
### Integration (opc-plc Docker, task #215)
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:
- `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
```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
```
Wire-level surfaces verified: `IDriver` + `IReadable` + `ISubscribable` +
`IHostConnectivityProbe` (via the Secure Channel exchange).
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.
### In-process wire tests (no opc-plc)
`OpcPlcFixture` probes `OPCUA_SIM_ENDPOINT` (default `opc.tcp://10.100.0.35:50000`)
and skips when unavailable. With an available, reserved fixture:
`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).
```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
```
### Unit
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.
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):
## Required compatibility evidence
- `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
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.
Capability surfaces whose contract is verified: `IDriver`, `ITagDiscovery`,
`IReadable`, `IWritable`, `ISubscribable`, `IHostConnectivityProbe`,
`IAlarmSource`.
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.
## What it does NOT cover
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.
### 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)
+3 -29
View File
@@ -9,9 +9,8 @@ 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 and alarm
operations use the upstream endpoint. HistoryRead uses the local server-wide
historian instead; see [History](#history-not-supported-gitea-655).
through the local OtOpcUa server. Browse, read, write, subscribe, alarm, and
history calls are passed through to the upstream endpoint.
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
@@ -21,31 +20,6 @@ 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 |
@@ -391,7 +365,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 dispatch,
cover the session lifecycle, namespace remapping, alarm/history passthrough,
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
@@ -1,284 +0,0 @@
# 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.
-31
View File
@@ -36,37 +36,6 @@ 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.
@@ -1,22 +0,0 @@
#Requires -Version 7.0
<#
.SYNOPSIS
Runs the production application SDK compatibility matrix in isolated processes.
.DESCRIPTION
Uses exact baseline host/client publish artifacts and builds the candidate from a
separate checkout. Both SDK versions remain in separate processes. Evidence,
artifact SHA-256 hashes, negotiated policies and individual results are retained.
The output directory must not exist. No fleet endpoint or store is used.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$BaselineRoot,
[Parameter(Mandatory)][string]$BaselineArtifacts,
[Parameter(Mandatory)][string]$CandidateRoot,
[Parameter(Mandatory)][string]$OutputDirectory,
[string]$ContainerImage = 'mcr.microsoft.com/dotnet/sdk:10.0.103-noble'
)
$ErrorActionPreference = 'Stop'
$runner = Join-Path $PSScriptRoot '../validation/opcua-sdk-compatibility/run.py'
& python3 $runner --baseline-root $BaselineRoot --baseline-artifacts $BaselineArtifacts --candidate-root $CandidateRoot --output $OutputDirectory --container-image $ContainerImage
exit $LASTEXITCODE
@@ -1,77 +0,0 @@
# HistorianGateway consumer TLS migration — issue 131
`consumer-1.0.0.patch` is a reviewable consumer change, kept separate from the
buildable application in this checkout. Real paired Client/Contracts 1.0.0
packages are now published and the isolated owning-project proof passed (RESULTS.md).
The application in this checkout remains pinned to published 0.4.0 until the patch
is applied by the coordinator.
The patch changes both central package pins to 1.0.0 and maps the new option as:
```csharp
AllowUntrustedServerCertificate = options.UseTls && options.AllowUntrustedServerCertificate,
```
The old inverse-mapping and obsolete h2c-history comments are removed. The existing
h2c construction test now includes a stale opt-out as well as a stale CA path.
Three new bounded local wire tests verify behavior through the real adapter and
published client API, without reflection or replacing the transport handler:
- TLS with normal defaults rejects a self-signed server before the metadata RPC.
- Explicit opt-out permits that self-signed server shape and executes one RPC.
- Plaintext with stale opt-out and CA settings executes one RPC, proving TLS-only
settings were not forwarded to the client's validating options.
The local server uses an ephemeral loopback port and a fresh short-lived
self-signed certificate. RPC completion is bounded to 15 seconds; the consumer
call timeout is two seconds. No gateway, deployment, or global trust store is used.
Before applying the patch, query the live Gitea NuGet version indexes for BOTH
packages and require actual stable 1.0.0. Source `main` having the new API is not a
package release. At initial inspection, both feed indexes listed exactly
0.4.0, 0.3.0, 0.2.0, 0.1.0, and the new API was only in Unreleased at HG commit
`e4321915ebc5e0704a68ecad21b85c1cc1c80d6b`.
```bash
git apply --check scripts/validation/historiangw-131/consumer-1.0.0.patch
git apply scripts/validation/historiangw-131/consumer-1.0.0.patch
# From the consumer checkout, using a fresh run/results directory:
hg131_results=/absolute/fresh/results
test ! -e "$hg131_results" || exit 2
mkdir -p "$hg131_results" || exit
hg131_test_status=0
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests \
-c Release --logger trx --results-directory "$hg131_results" || hg131_test_status=$?
hg131_guard_status=0
MIN_EXECUTED=1 bash scripts/ci/assert-not-all-skipped.sh "$hg131_results"/*.trx \
|| hg131_guard_status=$?
if [ "$hg131_test_status" -ne 0 ]; then exit "$hg131_test_status"; fi
exit "$hg131_guard_status"
```
Published-package verification must use the real 1.0.0 packages, preserve all test
and guard exits, run the full owning project, and complete the consumer solution
build/review before issue 131 is closed. The patch is transport-only and can be
applied independently of the OPC UA SDK 2 rollout.
For isolated validation, use a fresh output directory OUTSIDE both repositories
so the archived HG projects do not inherit the consumer's central package policy:
```bash
python3 scripts/validation/historiangw-131/validate.py \
--consumer-root "$PWD" \
--hg-root /Users/dohertj2/Desktop/HistorianGateway \
--output /tmp/historiangw-131-fresh-validation
```
The script checks both live feed indexes. If both actual stable 1.0.0 packages are
available, it validates them in an archived consumer copy. Otherwise it archives
exact HG commit e432191 and temporarily replaces ONLY the archived Gateway csproj's
two package references with source project references. This equivalent source
validation does not create or publish any fake package/version. Restore uses an
independent copy-on-write/copy cache, isolated HTTP cache and CLI home. Neither
user checkout is built or edited. Full owning-suite TRX, required TLS failure
classification, command exits and execution guard are retained under the task
output. Published mode additionally checks package assets/provenance, absence of
source overrides, and exact package-to-test-bin DLL hashes. Never treat source
validation as stable published-package verification.
@@ -1,98 +0,0 @@
# HG issue 131 — actual published 1.0.0 verification
Both real stable **Client 1.0.0** and **Contracts 1.0.0** were observed in the live
Gitea indexes at 2026-11-10 19:35:54 UTC and restored into a fresh isolated cache.
The future consumer patch was applied only to a separate SDK 2 consumer archive.
Application files in this working branch remain on 0.4.0, unchanged.
- Full Gateway owning project: **529 total, 523 passed, 0 failed, 6 existing live skips**.
- Restore, test and repository execution-count guard exits: **0 / 0 / 0**.
- All **3 real TLS/h2c wire cases passed** against the published packages.
- Strict default rejected with AuthenticationException **UntrustedRoot**, before RPC.
- Explicit opt-out and plaintext/stale-TLS-settings cases each completed one RPC.
- Both assets entries are **type=package, version=1.0.0**, from the actual Gitea
source. No source ProjectReference override: the archived Gateway csproj is
byte-identical to the normal consumer csproj.
- The only package folder is the task-isolated cache. Both copied test-bin DLL
hashes equal the package DLL hashes. No package was packed or published here.
Actual package RepositoryCommit for BOTH packages:
`ea9f21c850fb618767e12690924f9966f1e433ac`.
Consumer archive base: `4ea379fa92e571492ff9f49889a5eb91e3ad1e1a`.
Applied patch SHA-256: `9452ee82b2bace7828a384b9f6048e9c931e8b4ec2078e0ee1baa175d0652fac`.
| Package | NuGet package SHA-256 | Consumed DLL SHA-256 |
|---|---|---|
| ZB.MOM.WW.HistorianGateway.Client 1.0.0 | `0a3f2836727671c07c0c393e705cc9737d2ed44258213a51fb4acf0d0ab7129e` | `b89a02e0d228ec549c4dfb755694f7f67b573a6077f06ff2781165e884fc5e12` |
| ZB.MOM.WW.HistorianGateway.Contracts 1.0.0 | `83ffd12bcbbf919c973ba522560e10aa93a4cb8c686a3232030a2cfa5f00995e` | `e60d3d4650be5d251982f7847404daf1b666f61d56e2b0ccb844af8677e97743` |
Proof metadata/source/hash/readback checks:
`/tmp/historiangw-131-published-20261110T193638Z/published-package-proof.json`.
Full run metadata (including typed TLS error and package readback):
`/tmp/historiangw-131-published-20261110T193638Z/metadata.json`.
Fresh actual-package TRX: `/tmp/historiangw-131-published-20261110T193638Z/test-results`.
Restore/test/guard logs: `/tmp/historiangw-131-published-20261110T193638Z`.
This completes the isolated SDK 2 owning-project proof. The coordinator separately
owns the consumer-master full solution build, Astra review, landing/push and issue
131 comment/closure. No issue action, package publication, or HG user-checkout
mutation was performed by this validation lane. The live AVEVA fixture skips do
not substitute for the three executed local wire tests.
---
# Earlier archived-source validation
Future consumer patch validated against exact archived HG source commit
`e4321915ebc5e0704a68ecad21b85c1cc1c80d6b`. Consumer archive base:
`18c22cf7eb042126226e540d9b979907328055c3`. Patch SHA-256:
`9452ee82b2bace7828a384b9f6048e9c931e8b4ec2078e0ee1baa175d0652fac`.
## Scope and isolation
Mode: **unreleased source project references**, not published-package verification.
At that earlier source-validation time, both feed indexes still listed only
0.4.0, 0.3.0, 0.2.0, 0.1.0. The published-package run below supersedes that pending
release-availability status.
The user's HG checkout was only read by git archive. Only the disposable archived
consumer Gateway csproj had its two package references replaced by project
references into the exact archived HG source. No package was packed or published;
no fake 1.0.0 was inserted into any feed/cache. Restore used an independent copied
NuGet cache, isolated HTTP cache and CLI home. The normal consumer files remain
on published 0.4.0.
## Actual execution
- Full Gateway owning project: **529 total, 523 passed, 0 failed, 6 existing live skips**.
- Test exit **0**; repository nonzero-execution guard exit **0**.
- All three new local wire tests passed; no new skip or expected application-result change.
- Strict default: base HistorianGatewayException -> RpcException Status Internal ->
HttpRequestException -> AuthenticationException, certificate-chain **UntrustedRoot**.
The test explicitly requires the typed TLS authentication failure; RPC calls = **0**.
- Explicit TLS opt-out: actual metadata RPC succeeds; calls = **1**.
- Plaintext with stale opt-out and nonexistent CA path: actual metadata RPC succeeds;
calls = **1**.
The first source run compiled and executed but failed one new assertion because
it predicted the narrower HistorianGatewayUnavailableException type. The actual
TLS exception is the public base HistorianGatewayException; the corrected assertion
retains the required AuthenticationException graph and zero-RPC check. The fresh
second unfiltered run above passed. The six skipped cases are the existing AVEVA
live fixture tests; they are not a substitute for TLS wire coverage.
Completed UTC: `2026-11-10T19:33:17.272468+00:00`.
Actual metadata, typed TLS exception output and binary hashes:
`/tmp/historiangw-131-validation-20261110T193106Z/metadata-final.json`.
Fresh second-run TRX: `/tmp/historiangw-131-validation-20261110T193106Z/test-results2`.
Test log: `/tmp/historiangw-131-validation-20261110T193106Z/test2.log`.
Initial failed run and restore logs are retained in the same task directory.
Archived-source Client DLL SHA-256:
`87e995a43c63debdd8f648a8feb8d19d9110b45eb7b2b6906159077d90109862`.
Archived-source Contracts DLL SHA-256:
`ebaaa43ffc485288054eb864a59ec59264d8d9c4ea6871319fd7995192370751`.
That earlier run consumed source only. Actual stable-package proof is recorded
above. Consumer-master solution build/review/landing and issue handling are owned
by the coordinator; no issue comment/close was performed here.
@@ -1,226 +0,0 @@
diff --git a/Directory.Packages.props b/Directory.Packages.props
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -242,7 +242,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.4.0" />
- <PackageVersion Include="ZB.MOM.WW.HistorianGateway.Contracts" Version="0.4.0" />
+ <PackageVersion Include="ZB.MOM.WW.HistorianGateway.Client" Version="1.0.0" />
+ <PackageVersion Include="ZB.MOM.WW.HistorianGateway.Contracts" Version="1.0.0" />
</ItemGroup>
</Project>
\ No newline at end of file
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs
@@ -57,23 +57,15 @@
$"ServerHistorian:Endpoint must be an absolute http(s) URI (e.g. https://host:5222); got '{options.Endpoint}'.");
}
- // TLS-only options must stay at their defaults on a plaintext h2c connection: the client rejects
- // each of them outright when UseTls=false ("<name> is a TLS-only option and requires
- // UseTls=true"). Forwarding them unconditionally made h2c UNREACHABLE — AllowUntrustedServer-
- // Certificate defaults to false, so RequireCertificateValidation was always sent as true and
- // every http:// deployment crashed at startup, even though the scheme is documented as the
- // supported way to select h2c. There is no certificate to have a posture about here.
+ // Certificate settings apply only to TLS. The client rejects an explicit opt-out or a
+ // CA path on h2c, where there is no certificate to validate.
var clientOptions = new HistorianGatewayClientOptions
{
Endpoint = endpointUri,
ApiKey = options.ApiKey,
UseTls = options.UseTls,
CaCertificatePath = options.UseTls ? options.CaCertificatePath : null,
- // INVERTED mapping (TLS only): ServerHistorianOptions.AllowUntrustedServerCertificate
- // (opt-in to accept a self-signed cert) is the negation of the client's
- // RequireCertificateValidation. Allowing an untrusted cert == not requiring validation; a
- // pinned CaCertificatePath always verifies.
- RequireCertificateValidation = options.UseTls && !options.AllowUntrustedServerCertificate,
+ AllowUntrustedServerCertificate = options.UseTls && options.AllowUntrustedServerCertificate,
DefaultCallTimeout = options.CallTimeout,
LoggerFactory = loggerFactory,
};
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs
@@ -27,23 +27,6 @@
/// A plaintext <c>h2c</c> gateway (an <c>http://</c> endpoint with <c>UseTls=false</c>) must
/// construct, using nothing but documented defaults.
/// </summary>
- /// <remarks>
- /// <para>
- /// Found by the LocalDb Phase 2 live gate, on the third consecutive crash-loop of the rig.
- /// <c>docs/Historian.md</c> and CLAUDE.md both document <c>http://</c> as a supported
- /// transport ("scheme selects transport: https = TLS, http = h2c"), but it was in fact
- /// <b>unreachable</b>: this factory forwarded the TLS-only options unconditionally, and
- /// <see cref="ServerHistorianOptions.AllowUntrustedServerCertificate"/> defaults to
- /// <see langword="false"/>, so it always sent <c>RequireCertificateValidation=true</c> —
- /// which the client rejects outright when <c>UseTls=false</c>
- /// ("RequireCertificateValidation is a TLS-only option and requires UseTls=true").
- /// </para>
- /// <para>
- /// So <b>every</b> h2c deployment crashed at startup, and the only way to avoid it was to
- /// set <c>AllowUntrustedServerCertificate=true</c> — asserting a certificate posture for a
- /// connection that has no certificate at all.
- /// </para>
- /// </remarks>
[Fact]
public void Adapter_constructs_for_plaintext_h2c_endpoint()
{
@@ -71,6 +54,7 @@
ApiKey = "histgw_x_y",
UseTls = false,
CaCertificatePath = "/etc/ssl/certs/leftover-from-a-tls-config.pem",
+ AllowUntrustedServerCertificate = true,
};
using var adapter = HistorianGatewayClientAdapter.Create(opts, NullLoggerFactory.Instance);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayTlsPolicyWireTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayTlsPolicyWireTests.cs
new file mode 100644
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayTlsPolicyWireTests.cs
@@ -0,0 +1,142 @@
+using System.Net;
+using System.Security.Authentication;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using Grpc.Core;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Server.Kestrel.Core;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.HistorianGateway.Client;
+using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
+using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
+
+/// <summary>historiangw#131: verify the mapped TLS posture through an actual local gRPC connection.</summary>
+public sealed class HistorianGatewayTlsPolicyWireTests(ITestOutputHelper output)
+{
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ [Fact]
+ public async Task Default_tls_rejects_an_untrusted_server_before_the_rpc()
+ {
+ await using var server = await Server.StartAsync(tls: true);
+ using var adapter = HistorianGatewayClientAdapter.Create(server.Options(), NullLoggerFactory.Instance);
+
+ var error = await Should.ThrowAsync<HistorianGatewayException>(
+ () => adapter.TagExistsAsync("T", Ct).WaitAsync(TimeSpan.FromSeconds(15), Ct));
+ output.WriteLine($"TLS rejection: {error}");
+ ContainsTlsAuthenticationFailure(error).ShouldBeTrue("the failure must be TLS authentication, not an arbitrary connection failure");
+ server.MetadataCalls.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task Explicit_tls_opt_out_reaches_the_same_self_signed_server_shape()
+ {
+ await using var server = await Server.StartAsync(tls: true);
+ var options = server.Options(allowUntrusted: true);
+ using var adapter = HistorianGatewayClientAdapter.Create(options, NullLoggerFactory.Instance);
+
+ (await adapter.TagExistsAsync("T", Ct).WaitAsync(TimeSpan.FromSeconds(15), Ct)).ShouldBeTrue();
+ server.MetadataCalls.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task Plaintext_ignores_stale_tls_opt_out_and_ca_settings_and_reaches_the_rpc()
+ {
+ await using var server = await Server.StartAsync(tls: false);
+ var options = server.Options(allowUntrusted: true, caPath: "/nonexistent/historiangw-131-stale-ca.pem");
+ using var adapter = HistorianGatewayClientAdapter.Create(options, NullLoggerFactory.Instance);
+
+ (await adapter.TagExistsAsync("T", Ct).WaitAsync(TimeSpan.FromSeconds(15), Ct)).ShouldBeTrue();
+ server.MetadataCalls.ShouldBe(1);
+ }
+
+ private static bool ContainsTlsAuthenticationFailure(Exception error)
+ {
+ var pending = new Stack<Exception>();
+ var seen = new HashSet<Exception>(ReferenceEqualityComparer.Instance);
+ pending.Push(error);
+ while (pending.TryPop(out var current))
+ {
+ if (!seen.Add(current)) continue;
+ if (current is AuthenticationException) return true;
+ if (current.InnerException is { } inner) pending.Push(inner);
+ if (current is RpcException { Status.DebugException: { } debug }) pending.Push(debug);
+ }
+ return false;
+ }
+
+ private sealed class TagsService : HistorianTags.HistorianTagsBase
+ {
+ public int Calls;
+ public override Task<GetTagMetadataReply> GetTagMetadata(GetTagMetadataRequest request, ServerCallContext context)
+ {
+ Interlocked.Increment(ref Calls);
+ return Task.FromResult(new GetTagMetadataReply
+ { Metadata = new HistorianTagMetadata { Name = request.Tag } });
+ }
+ }
+
+ private sealed class Server(WebApplication app, TagsService service, string endpoint,
+ RSA? key, X509Certificate2? certificate) : IAsyncDisposable
+ {
+ public int MetadataCalls => Volatile.Read(ref service.Calls);
+ public ServerHistorianOptions Options(bool allowUntrusted = false, string? caPath = null) => new()
+ {
+ Endpoint = endpoint, ApiKey = "histgw_131_test", UseTls = certificate is not null,
+ CallTimeout = TimeSpan.FromSeconds(2),
+ AllowUntrustedServerCertificate = allowUntrusted, CaCertificatePath = caPath,
+ };
+
+ public static async Task<Server> StartAsync(bool tls)
+ {
+ RSA? key = null;
+ X509Certificate2? certificate = null;
+ if (tls)
+ {
+ key = RSA.Create(2048);
+ var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
+ request.CertificateExtensions.Add(new X509KeyUsageExtension(
+ X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, true));
+ var names = new SubjectAlternativeNameBuilder();
+ names.AddDnsName("localhost"); names.AddIpAddress(IPAddress.Loopback);
+ request.CertificateExtensions.Add(names.Build());
+ certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddDays(1));
+ }
+ var service = new TagsService();
+ var builder = WebApplication.CreateBuilder();
+ builder.Logging.ClearProviders();
+ builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0, listen =>
+ {
+ listen.Protocols = HttpProtocols.Http2;
+ if (certificate is not null) listen.UseHttps(certificate);
+ }));
+ builder.Services.AddGrpc(); builder.Services.AddSingleton(service);
+ var app = builder.Build();
+ app.MapGrpcService<TagsService>();
+ try
+ {
+ await app.StartAsync(Ct);
+ return new Server(app, service, app.Urls.Single(), key, certificate);
+ }
+ catch
+ {
+ await app.DisposeAsync(); certificate?.Dispose(); key?.Dispose();
+ throw;
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ try { await app.StopAsync(Ct); }
+ finally { await app.DisposeAsync(); certificate?.Dispose(); key?.Dispose(); }
+ }
+ }
+}
@@ -1,126 +0,0 @@
#!/usr/bin/env python3
"""Validate the future patch in archives, using published 1.0.0 or exact HG source project references."""
import argparse, datetime, hashlib, io, json, os, shutil, subprocess, tarfile, urllib.request
import xml.etree.ElementTree as ET
from pathlib import Path
def verify_published_packages(root, consumer, cache, project):
relative = Path('src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj')
assert (consumer/relative).read_bytes() == (root/relative).read_bytes(), 'Unexpected source-project override'
assets = json.loads((consumer/project.parent/'obj/project.assets.json').read_text())
assert all(Path(p).resolve() == cache.resolve() for p in assets['packageFolders'])
proof = {'no_source_project_reference_override': True, 'packages': {}}
for name in ['Client', 'Contracts']:
ident = 'ZB.MOM.WW.HistorianGateway.' + name
library = assets['libraries'][ident + '/1.0.0']
assert library['type'] == 'package'
package = cache/ident.lower()/'1.0.0'
provenance = json.loads((package/'.nupkg.metadata').read_text())
assert provenance['source'].startswith('https://gitea.dohertylan.com/api/packages/dohertj2/nuget')
dll = package/'lib/net10.0'/(ident + '.dll')
copied = consumer/project.parent/'bin/Release/net10.0'/(ident + '.dll')
digest = hashlib.sha256(dll.read_bytes()).hexdigest()
assert digest == hashlib.sha256(copied.read_bytes()).hexdigest()
nuspec = ET.parse(package/(ident.lower() + '.nuspec'))
assert nuspec.findtext('.//{*}version') == '1.0.0'
repository = nuspec.find('.//{*}repository')
proof['packages'][ident] = {'version': '1.0.0', 'asset_type': 'package',
'source': provenance['source'], 'dll_sha256': digest,
'nupkg_sha256': hashlib.sha256((package/(ident.lower() + '.1.0.0.nupkg')).read_bytes()).hexdigest(),
'repository': repository.attrib if repository is not None else None,
'test_bin_dll_matches_package': True}
return proof
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--consumer-root', type=Path, required=True)
parser.add_argument('--hg-root', type=Path, required=True)
parser.add_argument('--hg-commit', default='e4321915ebc5e0704a68ecad21b85c1cc1c80d6b')
parser.add_argument('--output', type=Path, required=True)
a = parser.parse_args(); root = a.consumer_root.resolve(); out = a.output.resolve()
if out.is_relative_to(root) or out.is_relative_to(a.hg_root.resolve()):
raise ValueError('Use an output directory outside both checkouts so archived HG does not inherit consumer central-package settings.')
out.mkdir(parents=True, exist_ok=False)
patch = Path(__file__).with_name('consumer-1.0.0.patch').resolve()
project = Path('tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.csproj')
sha = subprocess.check_output(['git','-C',str(root),'rev-parse','HEAD'],text=True).strip()
hg_sha = subprocess.check_output(['git','-C',str(a.hg_root),'rev-parse',a.hg_commit],text=True).strip()
def archive(repo, rev, target):
target.mkdir()
data = subprocess.check_output(['git','-C',str(repo),'archive',rev])
with tarfile.open(fileobj=io.BytesIO(data)) as tar: tar.extractall(target, filter='data')
versions = {}
for name in ['client','contracts']:
url = 'https://gitea.dohertylan.com/api/packages/dohertj2/nuget/package/zb.mom.ww.historiangateway.' + name + '/index.json'
versions[name] = json.load(urllib.request.urlopen(url,timeout=20))['versions']
published = all('1.0.0' in v for v in versions.values())
metadata = {'utc':datetime.datetime.now(datetime.timezone.utc).isoformat(),'consumer_commit':sha,'hg_snapshot':hg_sha,
'mode':'published-1.0.0' if published else 'unreleased-source-project-reference',
'feed_versions':versions,'patch_sha256':hashlib.sha256(patch.read_bytes()).hexdigest(),
'published_package_verification':published,'normal_package_cache_modified':False}
(out/'metadata.json').write_text(json.dumps(metadata,indent=2))
consumer = out/'consumer'; archive(root,sha,consumer)
subprocess.run(['git','init','-q',str(consumer)],check=True)
subprocess.run(['git','-C',str(consumer),'apply',str(patch)],check=True)
if not published:
hg = out/'historiangw-source'; archive(a.hg_root,hg_sha,hg)
adapter = consumer/'src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj'
tree = ET.parse(adapter)
removed = 0
for group in tree.getroot().findall('ItemGroup'):
for item in list(group):
if item.tag=='PackageReference' and item.get('Include') in ['ZB.MOM.WW.HistorianGateway.Client','ZB.MOM.WW.HistorianGateway.Contracts']:
group.remove(item);removed += 1
assert removed==2
group = ET.SubElement(tree.getroot(),'ItemGroup')
for relative in ['clients/dotnet/ZB.MOM.WW.HistorianGateway.Client/ZB.MOM.WW.HistorianGateway.Client.csproj',
'src/ZB.MOM.WW.HistorianGateway.Contracts/ZB.MOM.WW.HistorianGateway.Contracts.csproj']:
ET.SubElement(group,'ProjectReference',Include=str(hg/relative))
tree.write(adapter,encoding='unicode')
metadata['temporary_override']='Only archived Gateway csproj: two package references replaced by exact archived HG project references; no package packed or published.'
# Copy cached dependencies independently, preferably APFS copy-on-write, into the isolated package root.
cache = out/'packages'; cache.mkdir()
assets = json.loads((root/project.parent/'obj/project.assets.json').read_text())
normal = Path(next(iter(assets['packageFolders'])))
for library in assets['libraries'].values():
if library.get('type')!='package':continue
relative = library['path']; source=normal/relative; target=cache/relative
target.parent.mkdir(parents=True,exist_ok=True)
if target.exists():continue
r=subprocess.run(['cp','-cR',str(source),str(target)],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
if r.returncode: shutil.copytree(source,target)
config = ET.parse(root/'NuGet.config')
for source in config.getroot().find('packageSources'):
if source.get('key')=='local-mxgw':
local = root/'nuget-packages'
if not local.exists():local=out/'empty-local';local.mkdir()
source.set('value',str(local))
config.write(out/'NuGet.config',encoding='unicode')
env=dict(os.environ,NUGET_PACKAGES=str(cache),NUGET_HTTP_CACHE_PATH=str(out/'http-cache'),DOTNET_CLI_HOME=str(out/'dotnet-home'),
DOTNET_SKIP_FIRST_TIME_EXPERIENCE='1',DOTNET_GENERATE_ASPNET_CERTIFICATE='false',DOTNET_CLI_TELEMETRY_OPTOUT='1')
commands=[]
def run(args,name):
with (out/name).open('w') as log:r=subprocess.run(args,cwd=consumer,env=env,stdout=log,stderr=subprocess.STDOUT)
commands.append({'argv':args,'exit':r.returncode,'log':name})
(out/'commands.json').write_text(json.dumps(commands,indent=2));print(name,'exit',r.returncode,flush=True)
return r.returncode
restore = run(['dotnet','restore',str(project),'--configfile',str(out/'NuGet.config'),'--packages',str(cache)],'restore.log')
metadata['restore_exit']=restore
if restore:
(out/'metadata.json').write_text(json.dumps(metadata,indent=2));raise SystemExit(restore)
results=out/'test-results'
test = run(['dotnet','test',str(project),'-c','Release','--no-restore','--logger','trx','--results-directory',str(results)],'test.log')
guard_env=env.copy();guard_env['MIN_EXECUTED']='1'
with (out/'guard.log').open('w') as log:
guard=subprocess.run(['bash',str(consumer/'scripts/ci/assert-not-all-skipped.sh'),*[str(p) for p in results.glob('*.trx')]],env=guard_env,stdout=log,stderr=subprocess.STDOUT)
metadata.update({'test_exit':test,'guard_exit':guard.returncode})
ns={'t':'http://microsoft.com/schemas/VisualStudio/TeamTest/2010'}
metadata['results']=[]
for trx in results.glob('*.trx'):
xml=ET.parse(trx);metadata['results'].append(xml.find('.//t:Counters',ns).attrib)
metadata['tls_cases']=[{'name':r.get('testName'),'outcome':r.get('outcome'),'output':'\n'.join(p.text or '' for p in r.findall('.//t:StdOut',ns)),
'error':r.findtext('.//t:Message','',ns)} for r in xml.findall('.//t:UnitTestResult',ns) if 'HistorianGatewayTlsPolicyWireTests' in r.get('testName','')]
if published:
metadata['package_proof'] = verify_published_packages(root, consumer, cache, project)
(out/'metadata.json').write_text(json.dumps(metadata,indent=2));print(json.dumps(metadata,indent=2),flush=True)
raise SystemExit(test if test else guard.returncode)
@@ -1,4 +0,0 @@
<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>
@@ -1,4 +0,0 @@
<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>
@@ -1 +0,0 @@
<Project><PropertyGroup><ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally></PropertyGroup></Project>
@@ -1,242 +0,0 @@
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; }
}
@@ -1,56 +0,0 @@
# 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,93 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(45));
var ct = deadline.Token;
var mode = args[0];
var root = Path.GetFullPath(args[1]);
if (mode == "seed")
{
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{ ["LocalDb:Path"] = Path.Combine(root, "localdb.db") }).Build();
await using var provider = new ServiceCollection().AddLogging().AddOtOpcUaLocalDb(config).BuildServiceProvider();
var presented = Environment.GetEnvironmentVariable("SMOKE_API_KEY") ?? throw new InvalidOperationException("Missing task credential.");
if (!ApiKeySecret.TryParse(presented, out var id, out var secret)) throw new InvalidOperationException("Invalid task credential format.");
var pepper = Environment.GetEnvironmentVariable("Security__ApiKeys__Pepper") ?? throw new InvalidOperationException("Missing task pepper.");
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
Clusters = new[] { new { ClusterId = "smoke", Name = "Smoke" } },
RawFolders = new[] { new { RawFolderId = "smoke", Name = "Smoke", ClusterId = "smoke" } },
ApiKeys = new[] { new { ApiKeyId = id, Name = "publish-smoke", Principal = "publish-smoke",
Enabled = true, SecretHash = ApiKeySecret.ComputeHash(secret, pepper), CreatedUtc = DateTime.UtcNow } },
NodeAcls = new[] { new { NodeAclId = "smoke-read", ClusterId = "smoke", Principal = "publish-smoke",
ScopeKind = 0, ScopeId = (string?)null, PermissionFlags = 7 } },
});
var revision = Convert.ToHexStringLower(SHA256.HashData(artifact));
await provider.GetRequiredService<IDeploymentArtifactCache>().StoreAsync("smoke", ZB.MOM.WW.OtOpcUa.Commons.Types.DeploymentId.NewId().ToString(), revision, artifact, ct);
Console.WriteLine(JsonSerializer.Serialize(new { mode, result = "seeded-through-public-cache", revision }));
return;
}
var telemetry = DefaultTelemetry.Create(_ => { });
var clientName = mode == "prepare-cli" ? "OtOpcUaClient" : "PublishSmokeClient";
var security = new SecurityConfiguration
{
ApplicationCertificate = new CertificateIdentifier { StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(root, "own"), SubjectName = "CN=" + clientName },
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(root, "issuer") },
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(root, "trusted") },
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(root, "rejected") },
AutoAcceptUntrustedCertificates = false,
};
var configuration = new ApplicationConfiguration(telemetry)
{
ApplicationName = clientName, ApplicationUri = "urn:localhost:" + clientName,
ApplicationType = ApplicationType.Client, SecurityConfiguration = security,
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
};
await using var application = new ApplicationInstance(telemetry)
{ ApplicationName = configuration.ApplicationName, ApplicationType = ApplicationType.Client, ApplicationConfiguration = configuration };
await configuration.ValidateAsync(ApplicationType.Client, ct);
await application.CheckApplicationInstanceCertificatesAsync(true, lifeTimeInMonths: 12, ct);
if (mode is "prepare" or "prepare-cli")
{
Console.WriteLine(JsonSerializer.Serialize(new { mode, result = "prepared", autoAccept = security.AutoAcceptUntrustedCertificates }));
return;
}
var endpoint = args[2];
var expected = args[3];
var credential = Environment.GetEnvironmentVariable("SMOKE_API_KEY") ?? throw new InvalidOperationException("Missing task credential.");
try
{
var selected = await CoreClientUtils.SelectEndpointAsync(configuration, endpoint, true, telemetry, ct)
?? throw new InvalidOperationException("No secure endpoint advertised.");
if (selected.SecurityMode != MessageSecurityMode.SignAndEncrypt || selected.SecurityPolicyUri != SecurityPolicies.Basic256Sha256)
throw new InvalidOperationException("Unexpected negotiated security policy.");
using var session = await new DefaultSessionFactory(telemetry).CreateAsync(configuration,
new ConfiguredEndpoint(null, selected, EndpointConfiguration.Create(configuration)), false, true,
"PublishSmoke", 60_000, new UserIdentity("apikey", Encoding.UTF8.GetBytes(credential)), default, ct);
if (expected != "allow") throw new InvalidOperationException("A denied test credential or peer was admitted.");
var response = await session.ReadAsync(null, 0, TimestampsToReturn.Both,
[new ReadValueId { NodeId = VariableIds.Server_ServerStatus_State, AttributeId = Attributes.Value }], ct);
if (response.Results.Count != 1 || StatusCode.IsBad(response.Results[0].StatusCode)) throw new InvalidOperationException("Server status read failed.");
Console.WriteLine(JsonSerializer.Serialize(new { mode, expected, result = "allowed", policy = selected.SecurityPolicyUri,
securityMode = selected.SecurityMode.ToString(), status = response.Results[0].StatusCode.ToString(), autoAccept = false }));
await session.CloseAsync(ct);
}
catch (ServiceResultException error) when (expected != "allow")
{
var accepted = expected == "key-deny"
? new[] { StatusCodes.BadUserAccessDenied, StatusCodes.BadIdentityTokenRejected, StatusCodes.BadIdentityTokenInvalid }
: new[] { StatusCodes.BadCertificateUntrusted, StatusCodes.BadSecurityChecksFailed, StatusCodes.BadSecureChannelClosed };
if (!accepted.Contains(error.StatusCode)) throw;
Console.WriteLine(JsonSerializer.Serialize(new { mode, expected, result = "denied", status = error.StatusCode.ToString(), autoAccept = false }));
}
@@ -1,11 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../../src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj" />
</ItemGroup>
</Project>
@@ -1,70 +0,0 @@
# Actual Host publish smoke
From an isolated candidate checkout, publish the actual Host and CLI for Linux ARM64
and Windows x64 using the install recipe's self-contained setting. Do not use a
checkout that another process is building, and choose a new artifact directory.
```bash
task_output=/absolute/fresh/publish-smoke
test ! -e "$task_output" || exit 2
mkdir -p "$task_output"
for rid in linux-arm64 win-x64; do
dotnet publish src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj \
-c Release -r "$rid" --self-contained true \
-p:PublishSingleFile=false -p:PublishTrimmed=false -m:1 \
-o "$task_output/$rid/host" || exit
dotnet publish src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj \
-c Release -r "$rid" --self-contained true \
-p:PublishSingleFile=false -p:PublishTrimmed=false -m:1 \
-o "$task_output/$rid/cli" || exit
done
dotnet publish scripts/validation/opcua-publish-smoke/PublishProbe.csproj \
-c Release -m:1 -o "$task_output/probe" || exit
python3 scripts/validation/opcua-publish-smoke/runtime.py --artifacts "$task_output"
```
The cached `mcr.microsoft.com/dotnet/sdk:10.0.103-noble` image must be ARM64 for this
Linux artifact. The SDK image supplies the small probe's framework; the actual
Host and CLI are launched through their published self-contained apphosts. There
is no Native AOT, trimming, or single-file transformation.
The runtime script creates a fresh private state directory, ephemeral keys and
loopback ports. A temporary preparation container seeds the LocalDb cache through
public production registration/cache APIs. The artifact contains an empty driver
configuration, one raw folder, and a real HMAC API key with read/browse/subscribe
grants. No production authentication seam is replaced. The actual Host uses
`Cluster:Roles=driver`, `ConfigSource:Mode=FetchAndCache`, an unreachable loopback
central endpoint, and task-only LocalDb, secrets SQLite and GUID PKI paths. Its
single-node Akka seed is local. Every container has `--network none`; no fleet
endpoint, ConfigDb, existing container, volume, or PKI is used.
One task-owned container runs the actual Host as its entrypoint, stops cleanly,
then starts the same executable with the same state and PKI again. Each boot must
serve healthy readiness, liveness and active endpoints, admit a trusted
Basic256Sha256 SignAndEncrypt API-key client, reject a wrong key, and allow an
actual published CLI read of ServerStatus.State with Good quality. The first boot
also rejects an untrusted server and an untrusted client. The two certificate
thumbprints must match. Both stops must exit zero without an OOM kill. An explicit
execution guard requires two boots and all six strict SDK probe cases.
The probe retains its public SDK ApplicationInstance throughout its session and
disposes it afterward. Peer autoaccept is false. The CLI's existing configuration
sets autoaccept true and has no strict-validation flag, so its successful reads
are not counted as strict peer-validation coverage. Its certificate is prepared
with its normal application name and URI using public SDK APIs and trusted by the
server before startup. A closed-port CLI preflight is an expected nonzero setup
check, not a passing connection case.
`commands.json`, per-case logs, health JSON and `summary.json` retain actual exits,
results and certificate identity. Secrets are supplied through a mode-0600 task
environment file, never written into recorded arguments, and the file is removed
at the end. Raw process output and this run's appended rolling-file output are
checked for task secrets before redacted copies are saved. Only the captured,
task-labelled container is removed; private bind-mounted state remains local for
inspection and must not be redistributed with release artifacts.
Windows cross-publish and PE/native/dependency inspection can run on macOS.
Windows service installation and start/stop remain a separate operator gate on
an approved Windows environment. The Wonder-box remote-execution hold still
applies: no WinRM, psbridge, or replacement remote-execution channel. This smoke
does not complete backend, load, failover, or two-hour soak validation.
@@ -1,152 +0,0 @@
#!/usr/bin/env python3
"""Smoke actual self-contained Linux Host/CLI artifacts with task-only PKI and LocalDb."""
import argparse, base64, datetime, hashlib, json, os, re, secrets, shutil, subprocess, time, uuid
from pathlib import Path
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--artifacts', required=True, type=Path)
p.add_argument('--image', default='mcr.microsoft.com/dotnet/sdk:10.0.103-noble')
a = p.parse_args(); out = a.artifacts.resolve()
run = out / ('runtime-' + uuid.uuid4().hex); run.mkdir(exist_ok=False)
state = run / 'state'; state.mkdir(); os.chmod(state, 0o700)
identifier = uuid.uuid4().hex[:12]
web, ua, akka = [20000 + secrets.randbelow(10000) for _ in range(3)]
while len({web, ua, akka}) != 3: web, ua, akka = [20000 + secrets.randbelow(10000) for _ in range(3)]
key = 'otua_' + secrets.token_hex(8) + '_' + secrets.token_urlsafe(32)
pepper, fetch = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
master = base64.b64encode(secrets.token_bytes(32)).decode()
env = {
'ASPNETCORE_URLS': f'http://127.0.0.1:{web}', 'ASPNETCORE_ENVIRONMENT': 'Production',
'Cluster__Roles__0': 'driver',
'Cluster__SystemName': 'otopcua', 'Cluster__Hostname': '127.0.0.1',
'Cluster__PublicHostname': '127.0.0.1', 'Cluster__Port': str(akka),
'Cluster__SeedNodes__0': f'akka.tcp://otopcua@127.0.0.1:{akka}',
'ConfigSource__Mode': 'FetchAndCache', 'ConfigSource__CentralFetchEndpoints__0': 'http://127.0.0.1:1',
'ConfigSource__ApiKey': fetch, 'ConfigSource__FetchTimeoutSeconds': '1',
'LocalDb__Path': '/state/localdb.db', 'Secrets__SqlitePath': '/state/secrets.db',
'ZB_SECRETS_MASTER_KEY': master, 'Security__ApiKeys__Pepper': pepper,
'Security__Ldap__Enabled': 'false', 'Security__Acl__Enabled': 'false',
'OpcUa__ApplicationName': 'PublishSmoke-' + identifier,
'OpcUa__ApplicationUri': 'urn:localhost:PublishSmoke:' + identifier,
'OpcUa__PublicHostname': 'localhost', 'OpcUa__OpcUaPort': str(ua),
'OpcUa__PkiStoreRoot': '/state/server-pki-' + identifier,
'OpcUa__AllowAnonymous': 'false', 'OpcUa__AutoAcceptUntrustedClientCertificates': 'false',
'OpcUa__EnabledSecurityProfiles__0': 'Basic256Sha256SignAndEncrypt',
'SMOKE_API_KEY': key, 'XDG_DATA_HOME': '/state/cli-data', 'HOME': '/state/home',
}
envfile = run / 'private.env'; envfile.write_text(''.join(f'{k}={v}\n' for k,v in env.items())); os.chmod(envfile, 0o600)
redactions = [key, key.split('_', 2)[2], pepper, fetch, master]
def clean(text):
for secret in redactions: text = text.replace(secret, '<ephemeral-secret>')
return text
records = []
raw_secret_findings = []
rolling_root = out / 'linux-arm64/host/logs'
rolling_offsets = {p: p.stat().st_size for p in rolling_root.glob('*') if p.is_file()}
def command(args, name, expected=0, timeout=90):
start = datetime.datetime.now(datetime.timezone.utc).isoformat()
result = subprocess.run(args, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout)
raw_secret_findings.append({'log': name, 'occurrences': sum(result.stdout.count(secret) for secret in redactions)})
(run / (name + '.log')).write_text(clean(result.stdout))
records.append({'name': name, 'utc': start, 'argv': [clean(str(x)) for x in args], 'exit': result.returncode})
(run / 'commands.json').write_text(json.dumps(records, indent=2))
if expected is not None and result.returncode != expected:
raise RuntimeError(f'{name}: exit {result.returncode}; see {run / (name + ".log")}')
return result
mounts = ['--mount', f'type=bind,src={out},dst=/work', '--mount', f'type=bind,src={state},dst=/state']
base = ['docker','run','--rm','--network','none','--label','codex.task=opcua-publish-smoke','--env-file',str(envfile),*mounts,'-w','/work/linux-arm64/host',a.image]
probe = ['dotnet','/work/probe/PublishProbe.dll']
cli = ['/work/linux-arm64/cli/ZB.MOM.WW.OtOpcUa.Client.CLI']
server_pki = state / ('server-pki-' + identifier)
container = None; summary = {'run': str(run), 'image': a.image, 'ports': {'http':web,'ua':ua,'akka':akka}, 'boots':[], 'cases':[]}
def copy_public(source, target):
target.mkdir(parents=True, exist_ok=True)
certs = list(source.glob('*.der'))
if not certs: raise RuntimeError(f'No public certificates at {source}')
for cert in certs: shutil.copy2(cert, target / cert.name)
def inspect(): return json.loads(subprocess.check_output(['docker','inspect',container], text=True))[0]
def save_logs(name):
r = subprocess.run(['docker','logs',container],text=True,stdout=subprocess.PIPE,stderr=subprocess.STDOUT)
raw_secret_findings.append({'log': name, 'occurrences': sum(r.stdout.count(secret) for secret in redactions)})
(run / (name + '.log')).write_text(clean(r.stdout))
def exec_probe(name, pki, expected, credential=None):
args = ['docker','exec']
if credential: args += ['-e','SMOKE_API_KEY=' + credential]
args += [container,*probe,'probe',pki,f'opc.tcp://localhost:{ua}/OtOpcUa',expected]
r = command(args,name)
payload = json.loads(r.stdout.strip().splitlines()[-1]); summary['cases'].append({'name':name,**payload})
try:
command([*base,*probe,'seed','/state'],'seed')
command([*base,*probe,'prepare','/state/client-pki'],'prepare-trusted-client')
command([*base,*probe,'prepare','/state/untrusted-client-pki'],'prepare-untrusted-client')
command([*base,*cli,'--help'],'cli-help')
command([*base,*probe,'prepare-cli','/state/cli-data/OtOpcUaClient/pki'],'prepare-cli-certificate')
failed = command([*base,*cli,'read','--url','opc.tcp://localhost:1/OtOpcUa','--security','encrypt','--username','apikey','--password',key,'--node','i=2259'],'cli-prepare-on-closed-port',expected=None)
if failed.returncode == 0: raise RuntimeError('Closed-port CLI fixture preparation unexpectedly succeeded.')
copy_public(state/'client-pki/own/certs',server_pki/'trusted/certs')
copy_public(state/'cli-data/OtOpcUaClient/pki/own/certs',server_pki/'trusted/certs')
name = 'otopcua-sdk2-publish-' + identifier
result = command(['docker','create','--name',name,'--network','none','--label','codex.task=opcua-publish-smoke','--env-file',str(envfile),*mounts,'-w','/work/linux-arm64/host',a.image,'./OtOpcUa.Host'],'container-create')
container = result.stdout.strip()
summary['container'] = container
for boot in [1,2]:
command(['docker','start',container],f'boot{boot}-start')
deadline = time.monotonic() + 90
health = None
while time.monotonic() < deadline:
if not inspect()['State']['Running']:
save_logs(f'boot{boot}-host'); raise RuntimeError('Full Host exited before health readiness.')
r = subprocess.run(['docker','exec',container,'curl','-fsS',f'http://127.0.0.1:{web}/health/ready'],text=True,stdout=subprocess.PIPE,stderr=subprocess.STDOUT)
if r.returncode == 0:
health = r.stdout; break
time.sleep(1)
if health is None:
save_logs(f'boot{boot}-host'); raise RuntimeError('Full Host readiness did not become healthy.')
if json.loads(health).get('status') != 'Healthy': raise RuntimeError('Readiness is degraded or unhealthy.')
(run/f'boot{boot}-ready.json').write_text(health)
command(['docker','exec',container,'curl','-fsS',f'http://127.0.0.1:{web}/healthz'],f'boot{boot}-live')
command(['docker','exec',container,'curl','-fsS',f'http://127.0.0.1:{web}/health/active'],f'boot{boot}-active')
certs = list((server_pki/'own/certs').glob('*.der'))
if len(certs) != 1: raise RuntimeError('Expected exactly one application certificate.')
cert_sha256 = hashlib.sha256(certs[0].read_bytes()).hexdigest()
thumbprint = subprocess.check_output(['openssl','x509','-inform','DER','-in',str(certs[0]),'-noout','-fingerprint','-sha1'],text=True).strip()
copy_public(server_pki/'own/certs',state/'client-pki/trusted/certs')
copy_public(server_pki/'own/certs',state/'untrusted-client-pki/trusted/certs')
exec_probe(f'boot{boot}-trusted-key','/state/client-pki','allow')
exec_probe(f'boot{boot}-wrong-key','/state/client-pki','key-deny',key+'-wrong')
if boot == 1:
hold = state/'hold-server-trust'; shutil.move(str(state/'client-pki/trusted/certs'),hold)
(state/'client-pki/trusted/certs').mkdir()
try: exec_probe('untrusted-server','/state/client-pki','peer-deny')
finally:
shutil.rmtree(state/'client-pki/trusted/certs'); shutil.move(str(hold),state/'client-pki/trusted/certs')
exec_probe('untrusted-client','/state/untrusted-client-pki','peer-deny')
r = command(['docker','exec',container,*cli,'read','--url',f'opc.tcp://localhost:{ua}/OtOpcUa','--security','encrypt','--username','apikey','--password',key,'--node','i=2259'],f'boot{boot}-actual-cli-read')
if not re.search(r'^Status:\s+Good(?:\s|$)', r.stdout, re.MULTILINE): raise RuntimeError('Actual CLI did not report a Good server status read.')
command(['docker','stop','--time','30',container],f'boot{boot}-stop',timeout=45)
stopped = inspect()['State']; save_logs(f'boot{boot}-host')
if stopped['Running'] or stopped['ExitCode'] != 0 or stopped['OOMKilled']: raise RuntimeError('Full Host did not shut down cleanly.')
summary['boots'].append({'boot':boot,'certificate_sha256':cert_sha256,'thumbprint':thumbprint,'exit':stopped['ExitCode'],'oomKilled':stopped['OOMKilled'],'health_ready':health})
if summary['boots'][0]['thumbprint'] != summary['boots'][1]['thumbprint']: raise RuntimeError('Application certificate changed between boots.')
summary['certificate_preserved'] = True
for path in rolling_root.glob('*'):
if not path.is_file(): continue
text = path.read_bytes()[rolling_offsets.get(path, 0):].decode(errors='replace')
raw_secret_findings.append({'log': 'rolling-' + path.name, 'occurrences': sum(text.count(secret) for secret in redactions)})
(run / ('rolling-' + path.name)).write_text(clean(text))
if len(summary['boots']) != 2 or len(summary['cases']) != 6: raise RuntimeError('Required smoke cases did not all execute.')
if any(x['occurrences'] for x in raw_secret_findings): raise RuntimeError('Task secret appeared in raw application output; see recorded occurrence counts.')
summary['result'] = 'passed'
finally:
if container:
save_logs('host-final')
owned = inspect()
if owned['Config']['Labels'].get('codex.task') != 'opcua-publish-smoke': raise RuntimeError('Task container ownership changed.')
command(['docker','rm','-f',container],'container-cleanup')
logs = '\n'.join(x.read_text(errors='replace') for x in run.glob('*.log'))
summary['task_secret_occurrences_in_saved_logs'] = sum(logs.count(secret) for secret in redactions)
summary['raw_log_secret_findings'] = raw_secret_findings
summary['completed_utc'] = datetime.datetime.now(datetime.timezone.utc).isoformat()
(run/'summary.json').write_text(json.dumps(summary,indent=2))
envfile.unlink(missing_ok=True)
print(json.dumps(summary,indent=2))
@@ -1,374 +0,0 @@
#pragma warning disable OTOPCUA0001 // This wire harness deliberately exercises public driver capabilities directly.
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using System.Reflection;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using ZB.MOM.WW.OtOpcUa.Client.Shared;
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
using AlarmEventArgs = ZB.MOM.WW.OtOpcUa.Client.Shared.Models.AlarmEventArgs;
using WriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest;
using SharedSecurityMode = ZB.MOM.WW.OtOpcUa.Client.Shared.Models.SecurityMode;
using CoreHistory = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
internal static class ProcessHost
{
static readonly DateTime Epoch = new(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc);
const string RawUri = "https://zb.com/otopcua/raw", UnsUri = "https://zb.com/otopcua/uns";
const string Folder = "Matrix/dev", Scalar = Folder + "/Scalar", Vector = Folder + "/Vector", Alarm = Folder + "/Alarm";
static readonly CancellationToken Ct = new CancellationTokenSource(TimeSpan.FromMinutes(4)).Token;
static string Password => Environment.GetEnvironmentVariable("OPCUA_MATRIX_PASSWORD") ?? throw new InvalidOperationException("Missing ephemeral credential");
static readonly List<object> Cases = [];
static readonly List<object> Observations = [];
static int Failures;
static string Root = "";
static object? Clr(DataValue v)
{
#if SDK2
return ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(v.WrappedValue);
#else
return v.Value;
#endif
}
static DateTime SourceTime(DataValue value)
{
#if SDK2
return value.SourceTimestamp.ToDateTime();
#else
return value.SourceTimestamp;
#endif
}
static DateTime ServerTime(DataValue value)
{
#if SDK2
return value.ServerTimestamp.ToDateTime();
#else
return value.ServerTimestamp;
#endif
}
static void Require(bool yes, string message) { if (!yes) throw new InvalidOperationException(message); }
static async Task Case(string name, Func<Task> action)
{
try { await action(); Cases.Add(new { name, status = "passed" }); }
catch (Exception e) { Failures++; Cases.Add(new { name, status = "failed", error = e.ToString() }); }
}
static async Task Wait(Func<bool> predicate)
{
for (int i = 0; i < 200; i++) { if (predicate()) return; await Task.Delay(50, Ct); }
Require(false, "Notification deadline exceeded");
}
static ConnectionSettings Settings(string endpoint) => new()
{
EndpointUrl = endpoint,
Username = "apikey",
Password = Password,
SecurityMode = SharedSecurityMode.SignAndEncrypt,
AutoAcceptCertificates = false,
CertificateStorePath = Environment.GetEnvironmentVariable("OPCUA_MATRIX_CLIENT_PKI") ?? Path.Combine(Root, "client-pki")
};
public static async Task<int> Main(string[] args)
{
Root = Path.GetFullPath(args[1]); Directory.CreateDirectory(Root);
try
{
switch (args[0])
{
case "local-data-path": Console.WriteLine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)); return 0;
case "prepare":
var type = typeof(OpcUaClientService).Assembly.GetType("ZB.MOM.WW.OtOpcUa.Client.Shared.Adapters.DefaultApplicationConfigurationFactory")!;
var factory = Activator.CreateInstance(type, true)!;
await (Task<ApplicationConfiguration>)type.GetMethod("CreateAsync")!.Invoke(factory, [Settings("opc.tcp://127.0.0.1:1"), Ct])!;
Console.WriteLine("PREPARED"); return 0;
case "prepare-driver": await PrepareDriver(); return 0;
case "driver": await Driver(args[2]); break;
case "server": await Serve(); return 0;
case "client": await Client(args[2]); break;
case "performance": await Performance(args[2]); break;
default: throw new ArgumentException("Expected prepare/server/client");
}
}
catch (Exception e) { Failures++; Cases.Add(new { name = "process", status = "failed", error = e.ToString() }); }
File.WriteAllText(Path.Combine(Root, "result.json"), JsonSerializer.Serialize(new { sdk = typeof(NodeId).Assembly.GetName().Version?.ToString(), process = Environment.ProcessId, cases = Cases, observations = Observations, failures = Failures, loadedSdkAssemblies = AppDomain.CurrentDomain.GetAssemblies().Where(a => a.GetName().Name!.StartsWith("Opc.Ua", StringComparison.Ordinal)).Select(a => new { name = a.GetName().Name, version = a.GetName().Version?.ToString(), path = a.Location }) }, new JsonSerializerOptions { WriteIndented = true }));
return Failures == 0 ? 0 : 1;
}
static async Task Serve()
{
var options = new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.Matrix",
ApplicationUri = $"urn:OtOpcUa.Matrix.{Guid.NewGuid():N}",
PublicHostname = "127.0.0.1",
PkiStoreRoot = Path.Combine(Root, "server-pki"),
AllowAnonymous = false,
AutoAcceptUntrustedClientCertificates = false,
EnabledSecurityProfiles = Environment.GetEnvironmentVariable("OPCUA_MATRIX_SERVER_SECURITY") == "none"
? [OpcUaSecurityProfile.None] : [OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt]
};
using (var listener = new TcpListener(IPAddress.Loopback, 0)) { listener.Start(); options.OpcUaPort = ((IPEndPoint)listener.LocalEndpoint).Port; }
var server = new OtOpcUaSdkServer();
await using var host = new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance, new Auth());
await host.StartAsync(server, Ct);
var nm = server.NodeManager!; var sink = new SdkAddressSpaceSink(nm);
var authority = new DataPlaneAuthorityState();
authority.Apply("matrix", "rev1", [new ApiKey { ApiKeyId = "matrix", Name = "matrix", SecretHash = "fixture", Principal = "matrix", Enabled = true, CreatedUtc = DateTime.UtcNow.AddDays(-1) }],
[new NodeAcl { NodeAclId = "all", ClusterId = "matrix", Principal = "matrix", ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = (NodePermissions)int.MaxValue }], new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
nm.DataPlaneAuthority = authority;
nm.Authorizer = new DataPlaneAuthorizer(authority, new TriePermissionEvaluator(authority.Tries), true, NullLogger.Instance, TimeProvider.System);
var history = new Source(Root); nm.HistorianDataSource = history;
sink.EnsureFolder(Folder, null, "dev", AddressSpaceRealm.Raw);
sink.EnsureFolder("Eq", null, "Eq", AddressSpaceRealm.Uns);
foreach (var realm in new[] { AddressSpaceRealm.Raw, AddressSpaceRealm.Uns })
{
var scalar = realm == AddressSpaceRealm.Raw ? Scalar : "Eq/Scalar";
var vector = realm == AddressSpaceRealm.Raw ? Vector : "Eq/Vector";
var parent = realm == AddressSpaceRealm.Raw ? Folder : "Eq";
sink.EnsureVariable(scalar, parent, "Scalar", "Int32", true, realm, "matrix.scalar");
sink.EnsureVariable(vector, parent, "Vector", "Int32", true, realm, isArray: true, arrayLength: 3);
sink.WriteValue(scalar, 17, 0, Epoch, realm); sink.WriteValue(vector, new[] { 1, 2, 3 }, 0, Epoch, realm);
}
sink.AddReference("Eq/Scalar", AddressSpaceRealm.Uns, Scalar, AddressSpaceRealm.Raw);
sink.MaterialiseAlarmCondition(Alarm, Folder, "Alarm", "OffNormalAlarm", 800, AddressSpaceRealm.Raw, isNative: true);
sink.WireAlarmNotifiers(Alarm, AddressSpaceRealm.Raw, ["Eq"], AddressSpaceRealm.Uns);
sink.EnsureVariable(Folder + "/UnsetTimestamp", Folder, "UnsetTimestamp", "Int32", false, AddressSpaceRealm.Raw);
sink.WriteValue(Folder + "/UnsetTimestamp", 3, 0, DateTime.MinValue, AddressSpaceRealm.Raw);
nm.NodeWriteGateway = new Gateway();
var state = new AlarmConditionSnapshot(true, false, true, true, AlarmShelvingKind.Unshelved, 800, "matrix-alarm");
nm.NativeAlarmAckRouter = _ => Task.Run(() => { state = state with { Acknowledged = true }; sink.WriteAlarmCondition(Alarm, state, DateTime.UtcNow, AddressSpaceRealm.Raw); });
File.WriteAllText(Path.Combine(Root, "endpoint.txt"), $"opc.tcp://127.0.0.1:{options.OpcUaPort}/OtOpcUa");
File.WriteAllText(Path.Combine(Root, "ready.json"), JsonSerializer.Serialize(new { endpoint = $"opc.tcp://127.0.0.1:{options.OpcUaPort}/OtOpcUa", process = Environment.ProcessId, sdk = typeof(NodeId).Assembly.GetName().Version?.ToString(), rawUri = RawUri, unsUri = UnsUri }));
int tick = 0;
while (!File.Exists(Path.Combine(Root, "stop")))
{
sink.WriteAlarmCondition(Alarm, state with { Message = ++tick % 2 == 0 ? "matrix-alarm" : "matrix-tick" }, DateTime.UtcNow, AddressSpaceRealm.Raw);
await Task.Delay(1000, Ct);
}
using var process = System.Diagnostics.Process.GetCurrentProcess();
File.WriteAllText(Path.Combine(Root, "server-resources.json"), JsonSerializer.Serialize(new { process.WorkingSet64, process.PrivateMemorySize64, process.HandleCount, threads=process.Threads.Count, cpuMilliseconds=process.TotalProcessorTime.TotalMilliseconds, allocatedBytes=GC.GetTotalAllocatedBytes(), collections=new[]{GC.CollectionCount(0),GC.CollectionCount(1),GC.CollectionCount(2)} }, new JsonSerializerOptions {WriteIndented=true}));
}
static async Task Client(string endpoint)
{
using var client = new OpcUaClientService();
await Case("trusted-SignAndEncrypt-API-key", async () => { var info = await client.ConnectAsync(Settings(endpoint), Ct); Require(info.SecurityMode == "SignAndEncrypt" && info.SecurityPolicyUri == SecurityPolicies.Basic256Sha256, "Unexpected negotiated policy"); Observations.Add(new { info.SecurityMode, info.SecurityPolicyUri }); });
if (!client.IsConnected) return;
var namespaces = (string[])Clr(await client.ReadValueAsync(VariableIds.Server_NamespaceArray, Ct))!;
var raw = (ushort)Array.IndexOf(namespaces, RawUri); var uns = (ushort)Array.IndexOf(namespaces, UnsUri);
NodeId ScalarId(ushort ns) => new(ns == raw ? Scalar : "Eq/Scalar", ns);
NodeId VectorId(ushort ns) => new(ns == raw ? Vector : "Eq/Vector", ns);
await Case("raw-UNS-URI-browse", async () =>
{
Require(raw > 0 && uns > 0, "Missing namespace URI");
var rawChildren = await client.BrowseAsync(new NodeId(Folder, raw), Ct);
var unsChildren = await client.BrowseAsync(new NodeId("Eq", uns), Ct);
Observations.Add(new { rawChildren, unsChildren });
// Supplement the production Shared results with unfiltered wire-reference metadata.
var adapter = typeof(OpcUaClientService).GetField("_session", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(client)!;
var sdkSession = (Opc.Ua.Client.Session)adapter.GetType().GetField("_session", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(adapter)!;
var (_, _, rawReferences) = await sdkSession.BrowseAsync(null, null, new NodeId(Folder, raw), 0u,
BrowseDirection.Forward, ReferenceTypeIds.References, true, 0u);
Observations.Add(new { rawReferences = (rawReferences.ToArray() ?? []).Select(reference => new { nodeId = reference.NodeId.ToString(), browseName = reference.BrowseName.ToString(), nodeClass = reference.NodeClass.ToString(), typeDefinition = reference.TypeDefinition.ToString(), referenceType = reference.ReferenceTypeId.ToString(), reference.IsForward }).ToArray() });
var expectedRaw = new[] { Scalar, Vector, Alarm, Folder + "/UnsetTimestamp" }.Select(id => new NodeId(id, raw).ToString());
Require(expectedRaw.All(id => rawChildren.Any(child => child.NodeId == id)), "Missing raw children: " + string.Join(",", expectedRaw.Where(id => !rawChildren.Any(child => child.NodeId == id))));
Require(new[] { "Eq/Scalar", "Eq/Vector" }.Select(id => new NodeId(id, uns).ToString()).All(id => unsChildren.Any(child => child.NodeId == id)), "Missing UNS scalar/array children");
});
await Case("initial-quality-explicit-source-and-server-timestamps", async () => { var initial = await client.ReadValueAsync(ScalarId(raw), Ct); Require(StatusCode.IsGood(initial.StatusCode), "Bad initial quality"); Require(SourceTime(initial) == Epoch && ServerTime(initial) > Epoch, "Lost explicit initial timestamps"); Observations.Add(new { sourceTimestamp = SourceTime(initial).ToString("O"), serverTimestamp = ServerTime(initial).ToString("O"), contract = "Source supplied as 2026-10-01T00:00:00Z; server timestamp observed without normalization or historian algorithm changes" }); });
foreach (var ns in new[] { raw, uns })
{
await Case($"scalar-read-write-ns-{ns}", async () => { Require(Clr(await client.ReadValueAsync(ScalarId(ns), Ct)) is int, "Scalar lost Int32 type"); Require(StatusCode.IsGood(await client.WriteValueAsync(ScalarId(ns), 42, Ct)), "Scalar write failed"); Require((int)Clr(await client.ReadValueAsync(ScalarId(ns), Ct))! == 42, "Scalar round trip failed"); });
await Case($"typed-array-read-write-ns-{ns}", async () => { Require(Clr(await client.ReadValueAsync(VectorId(ns), Ct)) is int[], "Array lost Int32[] type"); Require(StatusCode.IsGood(await client.WriteValueAsync(VectorId(ns), new[] { 7, 8, 9 }, Ct)), "Array write failed"); Require(((int[])Clr(await client.ReadValueAsync(VectorId(ns), Ct))!).SequenceEqual(new[] { 7, 8, 9 }), "Array round trip failed"); });
}
await Case("monitor-quality-timestamps", async () => { var seen = new ConcurrentQueue<DataValue>(); client.DataChanged += (_, e) => seen.Enqueue(e.Value); await client.SubscribeAsync(ScalarId(raw), 100, Ct); await client.WriteValueAsync(ScalarId(raw), 73, Ct); await Wait(() => seen.Any(v => Equals(Clr(v), 73))); var v = seen.Last(v => Equals(Clr(v), 73)); Require(StatusCode.IsGood(v.StatusCode) && SourceTime(v) > Epoch && ServerTime(v) > Epoch, "Bad monitor quality or missing timestamps"); Observations.Add(new { sourceTimestamp = SourceTime(v).ToString("O"), serverTimestamp = ServerTime(v).ToString("O"), status = v.StatusCode.ToString() }); await client.UnsubscribeAsync(ScalarId(raw), Ct); });
await Case("event-filter-and-native-ack", async () => { var alarms = new ConcurrentQueue<AlarmEventArgs>(); client.AlarmEvent += (_, a) => alarms.Enqueue(a); await client.SubscribeAlarmsAsync(ObjectIds.Server, 100, Ct); await Wait(() => alarms.Any(a => a.Message == "matrix-alarm" && a.ActiveState && a.Severity == 800)); var alarm = alarms.Last(a => a.Message == "matrix-alarm"); Require(alarm.EventId?.Length > 0, "Missing event ID"); Require(StatusCode.IsGood(await client.AcknowledgeAlarmAsync(new NodeId(Alarm, raw).ToString(), alarm.EventId!, "matrix-ack", Ct)), "Ack refused"); await Wait(() => alarms.Any(a => a.Message == "matrix-alarm" && a.AckedState)); await client.UnsubscribeAlarmsAsync(Ct); });
await Case("raw-history-CP-paging", async () => { var values = await client.HistoryReadRawAsync(ScalarId(raw), Epoch, Epoch.AddMinutes(1), 100, Ct); Require(values.Count == 25, "Expected 25 raw samples, got " + values.Count); Require(values.Select(v => Convert.ToInt32(Clr(v))).SequenceEqual(Enumerable.Range(0, 25)), "Raw page loss/duplicates/order"); });
await Case("event-history-CP-paging", async () =>
{
var filter = new EventFilter();
#if SDK2
filter.SelectClauses += new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("EventId")) { AttributeId = Attributes.Value };
#else
filter.SelectClauses.Add(new SimpleAttributeOperand {TypeDefinitionId=ObjectTypeIds.BaseEventType,BrowsePath=new QualifiedNameCollection {new QualifiedName("EventId")},AttributeId=Attributes.Value});
#endif
var events = await client.HistoryReadEventsAsync(new NodeId(Folder, raw), Epoch, Epoch.AddMinutes(1), filter, 100, Ct); Require(events.Count == 25, "Expected 25 event samples, got " + events.Count);
#if SDK2
var ids = events.Select(e => System.Text.Encoding.UTF8.GetString((byte[])ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(e.Fields[0])!));
#else
var ids=events.Select(e=>System.Text.Encoding.UTF8.GetString((byte[])e.Fields[0].Value));
#endif
Require(ids.SequenceEqual(Enumerable.Range(0, 25).Select(i => $"e{i:00}")), "Event history loss/duplicates/order across timestamp ties");
});
await Case("event-history-where-filter-positive-and-negative", async () =>
{
foreach (var type in new[] { ObjectTypeIds.BaseEventType, ObjectTypeIds.AuditEventType })
{
var filter = new EventFilter();
#if SDK2
filter.SelectClauses += new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("EventId")) { AttributeId = Attributes.Value };
filter.WhereClause.Push(FilterOperator.OfType, Variant.From(type));
#else
filter.SelectClauses.Add(new SimpleAttributeOperand(ObjectTypeIds.BaseEventType,new QualifiedName("EventId")){AttributeId=Attributes.Value});
filter.WhereClause.Push(FilterOperator.OfType,new LiteralOperand(new Variant(type)));
#endif
var events = await client.HistoryReadEventsAsync(new NodeId(Folder, raw), Epoch, Epoch.AddMinutes(1), filter, 100, Ct);
Require(events.Count == (type == ObjectTypeIds.BaseEventType ? 25 : 0), $"Where filter for {type} expected {(type == ObjectTypeIds.BaseEventType ? 25 : 0)} got {events.Count}");
}
});
var unset = await client.ReadValueAsync(new NodeId(Folder + "/UnsetTimestamp", raw), Ct);
Observations.Add(new { sourceTimestamp = SourceTime(unset).ToString("O"), serverTimestamp = ServerTime(unset).ToString("O"), timestampObservation = "Fixture source supplied DateTime.MinValue (unset). These are unmodified application/SDK wire observations; no historian algorithm or expected timestamp was changed." });
await Benchmark(client, ScalarId(raw), VectorId(raw));
await client.DisconnectAsync(Ct);
}
static OpcUaClientDriverOptions DriverOptions(string endpoint) => new()
{
EndpointUrl = endpoint,
SecurityPolicy = OpcUaSecurityPolicy.Basic256Sha256,
SecurityMode = OpcUaSecurityMode.SignAndEncrypt,
AuthType = OpcUaAuthType.Username,
Username = "apikey",
Password = Password,
AutoAcceptCertificates = false,
RawTags = [new("scalar",JsonSerializer.Serialize(new {nodeId=$"nsu={RawUri};s={Scalar}"}),true),
new("vector",JsonSerializer.Serialize(new {nodeId=$"nsu={RawUri};s={Vector}"}),true),
new("uns",JsonSerializer.Serialize(new {nodeId=$"nsu={UnsUri};s=Eq/Scalar"}),true)],
SessionName = "MatrixDriver",
ApplicationUri = "urn:localhost:MatrixDriver"
};
static string IsolatedLocalData()
{
var local = Path.GetFullPath(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData));
Require(local.StartsWith(Path.GetFullPath(Root) + Path.DirectorySeparatorChar, StringComparison.Ordinal), "Driver/CLI canonical PKI must be in task-owned root: " + local);
return local;
}
static async Task PrepareDriver()
{
var local = IsolatedLocalData();
await using var driver = new OpcUaClientDriver(DriverOptions("opc.tcp://127.0.0.1:1"), "matrix");
// The production configuration method creates the application's own certificate without
// attempting a session. Reflection is confined to preparation; all wire operations below
// call the unchanged public driver API.
var type = typeof(OpcUaClientDriver);
var hook = Activator.CreateInstance(type.GetNestedType("CertValidationHook", BindingFlags.NonPublic)!, true)!;
await (Task<ApplicationConfiguration>)type.GetMethod("BuildApplicationConfigurationAsync", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(driver, [hook, Ct])!;
File.WriteAllText(Path.Combine(Root, "driver-pki.txt"), Path.Combine(local, "OtOpcUa", "pki"));
}
static async Task Driver(string endpoint)
{
IsolatedLocalData();
await using var driver = new OpcUaClientDriver(DriverOptions(endpoint), "matrix");
await Case("driver-trusted-encrypted-API-key-connect", () => driver.InitializeAsync("{}", Ct));
await Case("driver-raw-UNS-URI-scalar-and-array-read", async () =>
{
var values = await driver.ReadAsync(["scalar", "vector", "uns"], Ct);
Require(values.Count == 3 && values.All(x => x.StatusCode == 0), "Read failure");
Require(values[0].Value is int && values[1].Value is int[] && values[2].Value is int, "Lost scalar/array type");
});
await Case("driver-scalar-array-write-roundtrip", async () =>
{
var writes = await driver.WriteAsync([new WriteRequest("scalar", 91), new WriteRequest("vector", new[] { 11, 12, 13 })], Ct);
Require(writes.All(x => x.StatusCode == 0), "Write failed");
var values = await driver.ReadAsync(["scalar", "vector"], Ct);
Require(Equals(values[0].Value, 91) && ((int[])values[1].Value!).SequenceEqual(new[] { 11, 12, 13 }), "Write round trip mismatch");
});
await Case("driver-monitor-quality-timestamps", async () =>
{
var seen = new ConcurrentQueue<DataValueSnapshot>();
driver.OnDataChange += (_, e) => seen.Enqueue(e.Snapshot);
var subscription = await driver.SubscribeAsync(["scalar"], TimeSpan.FromMilliseconds(100), Ct);
await driver.WriteAsync([new WriteRequest("scalar", 92)], Ct);
await Wait(() => seen.Any(x => Equals(x.Value, 92)));
var value = seen.Last(x => Equals(x.Value, 92));
Require(value.StatusCode == 0 && value.SourceTimestampUtc.HasValue && value.ServerTimestampUtc > Epoch, "Missing monitor quality/timestamps");
Observations.Add(new { value.SourceTimestampUtc, value.ServerTimestampUtc, value.StatusCode });
await driver.UnsubscribeAsync(subscription, Ct);
});
Observations.Add(new { capability = "driver-history", status = "not-applicable", evidence = "Driver has no IHistoryProvider/HistoryRead capability; .claude/rules/drivers.md #655. Shared/CLI own history." });
await driver.ShutdownAsync(Ct);
}
static async Task Performance(string endpoint)
{
using var client = new OpcUaClientService();
var info = await client.ConnectAsync(Settings(endpoint), Ct);
Require(info.SecurityMode == "SignAndEncrypt" && info.SecurityPolicyUri == SecurityPolicies.Basic256Sha256, "Unexpected benchmark endpoint policy");
var namespaces = (string[])Clr(await client.ReadValueAsync(VariableIds.Server_NamespaceArray, Ct))!;
var raw = (ushort)Array.IndexOf(namespaces, RawUri);
Require(raw > 0, "Missing benchmark raw namespace");
Observations.Add(new {info.SecurityMode, info.SecurityPolicyUri, tieredCompilation=Environment.GetEnvironmentVariable("DOTNET_TieredCompilation"), scope="Two scalar/Int32[3] variables; zero data monitors during measurement; five fixture variables and one native condition"});
await Case("warmed-production-client-read-write-measurement", () => Benchmark(client, new NodeId(Scalar, raw), new NodeId(Vector, raw)));
await client.DisconnectAsync(Ct);
}
static async Task Benchmark(OpcUaClientService client, NodeId scalar, NodeId vector)
{
// Local diagnostic subset; excludes reconnect, history and load.
var rounds = int.Parse(Environment.GetEnvironmentVariable("OPCUA_MATRIX_PERF_ROUNDS") ?? "3");
var warmups = int.Parse(Environment.GetEnvironmentVariable("OPCUA_MATRIX_PERF_WARMUPS") ?? "10");
var measured = int.Parse(Environment.GetEnvironmentVariable("OPCUA_MATRIX_PERF_MEASURED") ?? "50");
Require(rounds > 0 && warmups >= 0 && measured > 0, "Invalid performance sample counts");
for (int round = 1; round <= rounds; round++)
{
for (int warmup = 0; warmup < warmups; warmup++)
{
await client.ReadValueAsync(scalar, Ct); await client.WriteValueAsync(scalar, warmup, Ct);
await client.ReadValueAsync(vector, Ct); await client.WriteValueAsync(vector, new[] { warmup, warmup + 1, warmup + 2 }, Ct);
}
var samples = new Dictionary<string, List<double>>();
var before = System.Diagnostics.Process.GetCurrentProcess(); var cpu = before.TotalProcessorTime;
var allocated = GC.GetTotalAllocatedBytes(); var gc = new[] { GC.CollectionCount(0), GC.CollectionCount(1), GC.CollectionCount(2) };
foreach (var operation in new[] { "scalar-read", "scalar-write", "array-read", "array-write" })
{
var times = new List<double>();
for (int i = 0; i < measured; i++)
{
var start = System.Diagnostics.Stopwatch.GetTimestamp();
if (operation == "scalar-read") await client.ReadValueAsync(scalar, Ct);
else if (operation == "scalar-write") Require(StatusCode.IsGood(await client.WriteValueAsync(scalar, i, Ct)), "Benchmark scalar write");
else if (operation == "array-read") await client.ReadValueAsync(vector, Ct);
else Require(StatusCode.IsGood(await client.WriteValueAsync(vector, new[] { i, i + 1, i + 2 }, Ct)), "Benchmark array write");
times.Add(System.Diagnostics.Stopwatch.GetElapsedTime(start).TotalMilliseconds);
}
samples[operation] = times;
}
before.Refresh();
Observations.Add(new
{
benchmarkRound = round,
warmupOperationsPerKind = 10,
operationsPerKind = 50,
latencyMilliseconds = samples.ToDictionary(x => x.Key, x => new { p50 = x.Value.Order().ElementAt((int)Math.Ceiling(measured * 0.50) - 1), p95 = x.Value.Order().ElementAt((int)Math.Ceiling(measured * 0.95) - 1), p99 = x.Value.Order().ElementAt((int)Math.Ceiling(measured * 0.99) - 1), throughputOperationsPerSecond = measured * 1000.0 / x.Value.Sum() }),
clientCpuMilliseconds = (before.TotalProcessorTime - cpu).TotalMilliseconds,
clientAllocatedBytes = GC.GetTotalAllocatedBytes() - allocated, clientAllocatedBytesPerOperation = (GC.GetTotalAllocatedBytes() - allocated) / (4.0 * measured),
clientWorkingSetBytes = before.WorkingSet64,
clientPrivateBytes = before.PrivateMemorySize64,
clientThreads = before.Threads.Count,
clientHandles = before.HandleCount,
collections = new[] { GC.CollectionCount(0) - gc[0], GC.CollectionCount(1) - gc[1], GC.CollectionCount(2) - gc[2] }
});
}
}
sealed class Auth : IOpcUaUserAuthenticator
{
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct) => Task.FromResult(username == "apikey" && password == Password ? OpcUaUserAuthResult.AllowKey("matrix", [OpcUaDataPlaneRoles.WriteOperate, OpcUaDataPlaneRoles.AlarmAck], "matrix") : OpcUaUserAuthResult.Deny("Invalid ephemeral fixture key"));
}
sealed class Gateway : IOpcUaNodeWriteGateway { public Task<NodeWriteOutcome> WriteAsync(string id, object? value, AddressSpaceRealm realm, CancellationToken ct) => Task.FromResult(new NodeWriteOutcome(true, null)); }
sealed class Source(string root) : IHistorianDataSource
{
void Log(string operation, int rows) => File.AppendAllText(Path.Combine(root, "history-requests.jsonl"), JsonSerializer.Serialize(new { operation, rows }) + "\n");
public int MaxRawValuesPerRead => 7;
public Task<CoreHistory> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct)
{ var all = Enumerable.Range(0, 25).Select(i => new DataValueSnapshot(i, 0, Epoch.AddSeconds(i), Epoch.AddSeconds(i))).Where(v => v.SourceTimestampUtc >= s && v.SourceTimestampUtc < e).ToList(); var rows = all.Take((int)(m == 0 ? 7 : m)).ToArray(); Log("raw", rows.Length); return Task.FromResult(new CoreHistory(rows, all.Count > rows.Length ? [1] : null)); }
public Task<HistoricalEventsResult> ReadEventsAsync(string? source, DateTime s, DateTime e, int max, CancellationToken ct)
{ var all = Enumerable.Range(0, 25).Select(i => new HistoricalEvent($"e{i:00}", "matrix", Epoch.AddSeconds(i / 2), Epoch, "matrix-history", 500)).Where(v => v.EventTimeUtc >= s && v.EventTimeUtc < e).ToList(); var rows = all.Take(Math.Min(7, max <= 0 ? 7 : max)).ToArray(); Log("events", rows.Length); return Task.FromResult(new HistoricalEventsResult(rows, all.Count > rows.Length ? [1] : null)); }
public Task<CoreHistory> ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) => NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct);
public Task<CoreHistory> ReadAtTimeAsync(string r, IReadOnlyList<DateTime> t, CancellationToken ct) => NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct);
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
public void Dispose() { }
}
}
@@ -1,96 +0,0 @@
# OPC UA SDK process compatibility
Run from a dedicated candidate checkout with .NET 10, Python 3, PowerShell 7,
and Docker. Supply the unchanged original application's existing `host/` and
`client/` publish directories. The output directory must not already exist.
```powershell
./scripts/e2e/test-opcua-sdk-compatibility.ps1 `
-BaselineRoot /absolute/original-checkout `
-BaselineArtifacts /absolute/original-publishes `
-CandidateRoot /absolute/dedicated-candidate-checkout `
-OutputDirectory /absolute/fresh-evidence-directory
```
The baseline console references the exact published production assemblies;
it does not rebuild or edit the original application. The candidate console
references the production host, Client.Shared and OPC UA driver projects, and
the candidate CLI is separately published. Different SDK versions always run
in different processes. Source revisions, SDK assemblies, SHA-256 hashes,
commands, per-case results, actual namespace URIs, negotiated Shared security,
loaded client SDK assemblies, timestamps and backend request logs are retained.
The required Shared legs are original→candidate, candidate→original and
candidate→candidate. A fourth original→original leg provides a reference for
both the application contract and the small performance sample. Every Shared
leg checks the exact scalar, array and condition browse identities, both
namespaces by URI, typed scalar/Int32[3] read/write, monitored Good quality and
source/server timestamps, the production alarm event filter and native
acknowledgment, and complete raw/event history across continuation points.
Event-history results include timestamp ties, exact IDs/order and positive and
negative `OfType` predicates. The historian is a deterministic fixture behind
the production server's session-bound continuation store; these results do
not certify any SQL Server, QuestDB or AVEVA backend.
Server, Shared client and driver require trusted Basic256Sha256
SignAndEncrypt peers, with certificate autoaccept disabled. Public certificates
are exchanged before sessions open. The test authenticator accepts one exact,
random, API-key-shaped credential from the process environment; production
ACL, write and condition-method checks use the resulting key identity. This
fixture does not replace cryptographic API-key-authenticator coverage.
The driver and actual CLI run in a disposable `--network none` Linux container,
alongside a separate baseline or candidate server process. PKI lives only in
the mounted task directory; no host profile PKI or fleet endpoint is touched.
The driver hardcodes `LocalApplicationData/OtOpcUa/pki`, so Linux's task-local
`XDG_DATA_HOME` supplies isolation. The helper refuses a driver store outside
its task root. CLI uses its own task-local canonical store. CLI currently
forces `AutoAcceptCertificates=true` and exposes no strict-validation flag
(`CommandBase.CreateConnectionSettings`); the exchanged peers and encrypted
server remain in use, but CLI is not counted as strict client validation.
Driver history is N/A because the application removed that capability (#655);
Shared and CLI execute history reads instead.
Three measured rounds follow ten warmup operations per kind in each round:
50 scalar reads, scalar writes, array reads and array writes per kind. The
sample records nearest-rank p50/p95/p99, sequential throughput, client CPU,
allocated bytes, GC collections, working set, private bytes, threads and
handles. This is a small local diagnostic on a shared development host. It
has no production fan-out, reconnect, queue, backpressure, backend or soak
performance claim and does not establish Gate G4.
A separately published `server` helper can be used by other local consumer
checks. Supply a fresh directory and `OPCUA_MATRIX_PASSWORD` in the process
environment, then launch `dotnet ProcessHost.dll server <directory>`.
`ready.json`/`endpoint.txt` identify the ephemeral loopback port; touching
`<directory>/stop` shuts it down. Its default is strict SignAndEncrypt only.
`OPCUA_MATRIX_SERVER_SECURITY=none` is an explicit fixture-only override for
checking an existing unsecured consumer subset; the matrix runner clears that
override. Anonymous is disabled in either mode. Secure clients must place
their public certificates under `server-pki/trusted/certs` before server start
and trust the resulting `server-pki/own/certs` certificate.
Explicitly supplied timestamps are asserted. Unset source timestamps are
recorded separately as observations, with their original application/SDK wire
values. The harness does not change historian algorithms or normalize the
unset-timestamp sentinel to manufacture parity.
For the supplementary sustained warmup experiment, use the built consoles from
one completed matrix run:
```sh
python3 scripts/validation/opcua-sdk-compatibility/performance.py \
--baseline-harness /absolute/run/build-baseline/bin/Release/net10.0/ProcessHost.dll \
--candidate-harness /absolute/run/build-candidate/bin/Release/net10.0/ProcessHost.dll \
--output /absolute/fresh-performance-directory
```
This runs three fresh baseline/candidate process pairs serially, with 1,000
warmup and 2,000 measured operations per kind in each process. Both server and
client receive `DOTNET_TieredCompilation=0` for this experiment only; the
application's shipped configuration is unchanged. The recorded budget is a
15% increase in the median of the three p95 samples for any operation kind.
Exit code 2 means a diagnostic budget regression, while process failures
remain errors. Even an experiment within that budget does not certify the
complete production performance matrix. The allocation-per-operation field
is the average across all four operation kinds in a measured workload.
@@ -1,34 +0,0 @@
#!/usr/bin/env bash
# Runs only inside the disposable, network-isolated task container.
set -euo pipefail
mkdir -p /task/driver/data /task/cli/data /task/server
XDG_DATA_HOME=/task/driver/data dotnet /candidate/ProcessHost.dll prepare-driver /task/driver > /task/driver-prepare.log 2>&1
OPCUA_MATRIX_CLIENT_PKI=/task/cli/data/OtOpcUaClient/pki XDG_DATA_HOME=/task/cli/data dotnet /candidate/ProcessHost.dll prepare /task/cli > /task/cli-prepare.log 2>&1
mkdir -p /task/server/server-pki/trusted/certs
cp /task/driver/data/OtOpcUa/pki/own/certs/* /task/server/server-pki/trusted/certs/
cp /task/cli/data/OtOpcUaClient/pki/own/certs/* /task/server/server-pki/trusted/certs/
dotnet /server/ProcessHost.dll server /task/server > /task/server.log 2>&1 &
server_pid=$!
cleanup() {
touch /task/server/stop
wait "$server_pid" || true
}
trap cleanup EXIT
for attempt in $(seq 1 600); do
test ! -f /task/server/ready.json || break
kill -0 "$server_pid" || exit 1
sleep 0.1
done
test -f /task/server/ready.json
endpoint=$(cat /task/server/endpoint.txt)
mkdir -p /task/driver/data/OtOpcUa/pki/trusted/certs /task/cli/data/OtOpcUaClient/pki/trusted/certs
cp /task/server/server-pki/own/certs/* /task/driver/data/OtOpcUa/pki/trusted/certs/
cp /task/server/server-pki/own/certs/* /task/cli/data/OtOpcUaClient/pki/trusted/certs/
result=0
XDG_DATA_HOME=/task/driver/data dotnet /candidate/ProcessHost.dll driver /task/driver "$endpoint" > /task/driver.log 2>&1 || result=1
# CLI currently autoaccepts by product design; it has no strict-validation flag.
# Its public certificates are nevertheless explicitly exchanged in this fixture.
XDG_DATA_HOME=/task/cli/data dotnet /cli/ZB.MOM.WW.OtOpcUa.Client.CLI.dll read --url "$endpoint" --security signandencrypt --username apikey --password "$OPCUA_MATRIX_PASSWORD" --node 'ns=2;s=Matrix/dev/Scalar' > /task/cli-read.log 2>&1 || result=1
XDG_DATA_HOME=/task/cli/data dotnet /cli/ZB.MOM.WW.OtOpcUa.Client.CLI.dll historyread --url "$endpoint" --security signandencrypt --username apikey --password "$OPCUA_MATRIX_PASSWORD" --node 'ns=2;s=Matrix/dev/Scalar' --start 2026-10-01T00:00:00Z --end 2026-10-01T00:01:00Z --max 100 > /task/cli-raw-history.log 2>&1 || result=1
XDG_DATA_HOME=/task/cli/data dotnet /cli/ZB.MOM.WW.OtOpcUa.Client.CLI.dll historyread --url "$endpoint" --security signandencrypt --username apikey --password "$OPCUA_MATRIX_PASSWORD" --node 'ns=2;s=Matrix/dev' --start 2026-10-01T00:00:00Z --end 2026-10-01T00:01:00Z --max 100 --events --fields EventId,Message > /task/cli-event-history.log 2>&1 || result=1
exit "$result"
@@ -1,115 +0,0 @@
#!/usr/bin/env python3
"""Supplementary warmed, matched native-process experiment; not full Gate G4."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import secrets
import shutil
import statistics
import subprocess
import time
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--baseline-harness', type=Path, required=True)
parser.add_argument('--candidate-harness', type=Path, required=True)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--tiered-compilation', choices=['0', '1'], default='0')
args = parser.parse_args()
output = args.output.resolve()
output.mkdir(parents=True, exist_ok=False)
env = dict(os.environ, OPCUA_MATRIX_PASSWORD='otua_matrix_' + secrets.token_urlsafe(32),
DOTNET_TieredCompilation=args.tiered_compilation, OPCUA_MATRIX_PERF_ROUNDS='1',
OPCUA_MATRIX_PERF_WARMUPS='1000', OPCUA_MATRIX_PERF_MEASURED='2000')
for key in ('OPCUA_MATRIX_SERVER_SECURITY', 'OPCUA_MATRIX_CLIENT_PKI'):
env.pop(key, None)
metadata = {'scope': __doc__, 'environment': {k: env[k] for k in ('DOTNET_TieredCompilation', 'OPCUA_MATRIX_PERF_ROUNDS', 'OPCUA_MATRIX_PERF_WARMUPS', 'OPCUA_MATRIX_PERF_MEASURED')},
'dotnetSdk': subprocess.check_output(['dotnet', '--version'], text=True).strip(), 'p95BudgetPercent': 15,
'dateUtc': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'logicalCpuCount': os.cpu_count()}
commands, results, hashes = [], [], []
def write(path, data):
path.write_text(json.dumps(data, indent=2) + '\n')
def run(command, log):
commands.append({'argv': list(map(str, command)), 'log': str(log)})
with log.open('w') as stream:
result = subprocess.run(list(map(str, command)), stdout=stream, stderr=subprocess.STDOUT, env=env, timeout=240)
if result.returncode:
raise RuntimeError(f'Process exited {result.returncode}; {log}')
def copy_certs(source, destination):
destination.mkdir(parents=True, exist_ok=True)
files = list(source.glob('*'))
if not files:
raise RuntimeError('Missing public certificates: ' + str(source))
for path in files:
shutil.copy2(path, destination / path.name)
try:
for pair in range(1, 4):
for version, harness in [('baseline', args.baseline_harness.resolve()), ('candidate', args.candidate_harness.resolve())]:
leg = output / f'pair-{pair}-{version}'
leg.mkdir()
client, server = leg / 'client', leg / 'server'
client.mkdir()
server.mkdir()
run(['dotnet', harness, 'prepare', client], leg / 'prepare.log')
copy_certs(client / 'client-pki/own/certs', server / 'server-pki/trusted/certs')
server_log = (leg / 'server.log').open('w')
command = ['dotnet', str(harness), 'server', str(server)]
commands.append({'argv': command, 'log': str(leg / 'server.log')})
process = subprocess.Popen(command, env=env, stdout=server_log, stderr=subprocess.STDOUT)
try:
deadline = time.monotonic() + 60
while not (server / 'ready.json').exists():
if process.poll() is not None or time.monotonic() > deadline:
raise RuntimeError('Server failed to start: ' + str(leg / 'server.log'))
time.sleep(.1)
ready = json.loads((server / 'ready.json').read_text())
copy_certs(server / 'server-pki/own/certs', client / 'client-pki/trusted/certs')
run(['dotnet', harness, 'performance', client, ready['endpoint']], leg / 'client.log')
result = json.loads((client / 'result.json').read_text())
result.update({'pair': pair, 'version': version, 'server': ready})
finally:
(server / 'stop').touch()
try:
process.wait(timeout=15)
except subprocess.TimeoutExpired:
process.terminate()
process.wait(timeout=10)
server_log.close()
result['serverResources'] = json.loads((server / 'server-resources.json').read_text())
results.append(result)
write(leg / 'measurement.json', result)
summary = []
for operation in ('scalar-read', 'scalar-write', 'array-read', 'array-write'):
samples = {}
for version in ('baseline', 'candidate'):
rounds = [o for r in results if r['version'] == version for o in r['observations'] if 'benchmarkRound' in o]
samples[version] = {'p95Milliseconds': [r['latencyMilliseconds'][operation]['p95'] for r in rounds],
'allocatedBytesPerOperation': [r['clientAllocatedBytesPerOperation'] for r in rounds],
'clientWorkingSetBytes': [r['clientWorkingSetBytes'] for r in rounds],
'serverWorkingSetBytes': [r['serverResources']['WorkingSet64'] for r in results if r['version'] == version]}
ratio = statistics.median(samples['candidate']['p95Milliseconds']) / statistics.median(samples['baseline']['p95Milliseconds'])
summary.append({'operation': operation, 'samples': samples, 'medianP95ChangePercent': 100 * (ratio - 1), 'status': 'diagnostic-regression' if ratio > 1.15 else 'diagnostic-within-budget'})
metadata['summary'] = summary
metadata['status'] = 'diagnostic-regression' if any(r['status'] == 'diagnostic-regression' for r in summary) else 'diagnostic-within-budget'
for row in summary:
print(f"{row['operation']}: median p95 {row['medianP95ChangePercent']:+.1f}% ({row['status']})")
finally:
for harness in (args.baseline_harness.resolve(), args.candidate_harness.resolve()):
for path in [harness, *harness.parent.glob('Opc.Ua*.dll'), *harness.parent.glob('ZB.MOM.WW.OtOpcUa*.dll'), *harness.parent.glob('OtOpcUa.Host.dll')]:
hashes.append({'path': str(path), 'sha256': hashlib.sha256(path.read_bytes()).hexdigest(), 'bytes': path.stat().st_size})
write(output / 'artifact-hashes.json', hashes)
write(output / 'commands.json', commands)
write(output / 'measurements.json', results)
write(output / 'summary.json', metadata)
return 2 if metadata.get('status') == 'diagnostic-regression' else 0
if __name__ == '__main__':
raise SystemExit(main())
@@ -1,194 +0,0 @@
#!/usr/bin/env python3
"""Separate-process production application compatibility gate (no mixed SDK process)."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import secrets
import shutil
import subprocess
import time
import uuid
import xml.etree.ElementTree as ET
HERE = Path(__file__).resolve().parent
HOST = 'src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj'
CLIENT = 'src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj'
DRIVER = 'src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj'
CLI = 'src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj'
def write_json(path, value):
path.write_text(json.dumps(value, indent=2) + '\n')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--baseline-root', required=True, type=Path)
parser.add_argument('--baseline-artifacts', required=True, type=Path, help='Existing immutable host/ and client/ publishes')
parser.add_argument('--candidate-root', required=True, type=Path, help='Dedicated candidate checkout; this script builds it')
parser.add_argument('--container-image', default='mcr.microsoft.com/dotnet/sdk:10.0.103-noble', help='Disposable local driver/CLI isolation; must supply .NET 10 and bash')
parser.add_argument('--output', required=True, type=Path, help='Fresh nonexistent evidence directory')
args = parser.parse_args()
args.baseline_root = args.baseline_root.resolve()
args.candidate_root = args.candidate_root.resolve()
args.baseline_artifacts = args.baseline_artifacts.resolve()
args.output = args.output.resolve()
args.output.mkdir(parents=True, exist_ok=False)
source_info = {}
for name, root in [('baseline', args.baseline_root), ('candidate', args.candidate_root)]:
source_info[name] = {'root': str(root), 'commit': subprocess.check_output(['git', '-C', str(root), 'rev-parse', 'HEAD'], text=True).strip(),
'sdkPackages': str(root / 'Directory.Packages.props')}
source_info['scope'] = 'Loopback application contract and 3 warmed 50-operation rounds per scalar/Int32[3] read/write kind; shared development host, no full load/reconnect/failover/backend/soak performance claim.'
source_info['dateUtc'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
write_json(args.output / 'source-info.json', source_info)
commands = []
matrix = []
env = dict(os.environ, OPCUA_MATRIX_PASSWORD='otua_matrix_' + secrets.token_urlsafe(32))
env.pop('OPCUA_MATRIX_CLIENT_PKI', None)
env.pop('OPCUA_MATRIX_SERVER_SECURITY', None)
def run(command, log, process_env=env, timeout=300):
commands.append({'argv': ['<ephemeral-key>' if x == env['OPCUA_MATRIX_PASSWORD'] else str(x) for x in command], 'log': str(log)})
with log.open('w') as stream:
result = subprocess.run([str(x) for x in command], stdout=stream, stderr=subprocess.STDOUT, env=process_env, timeout=timeout)
return result.returncode
def build(version):
folder = args.output / ('build-' + version)
folder.mkdir()
project = ET.Element('Project', Sdk='Microsoft.NET.Sdk')
props = ET.SubElement(project, 'PropertyGroup')
for k, v in {'OutputType': 'Exe', 'TargetFramework': 'net10.0', 'Nullable': 'enable', 'ImplicitUsings': 'enable', 'LangVersion': 'latest', 'DefineConstants': 'SDK2' if version == 'candidate' else ''}.items():
ET.SubElement(props, k).text = v
items = ET.SubElement(project, 'ItemGroup')
ET.SubElement(items, 'FrameworkReference', Include='Microsoft.AspNetCore.App')
if version == 'candidate':
for path in (HOST, CLIENT, DRIVER):
ET.SubElement(items, 'ProjectReference', Include=str(args.candidate_root / path))
else:
dlls = {}
for path in ('host', 'client'):
for dll in (args.baseline_artifacts / path).glob('*.dll'):
dlls[dll.name] = dll
for name, dll in sorted(dlls.items()):
ref = ET.SubElement(items, 'Reference', Include=name[:-4])
ET.SubElement(ref, 'HintPath').text = str(dll)
ET.SubElement(ref, 'Private').text = 'true'
ET.SubElement(items, 'Compile', Include=str(HERE / 'ProcessHost.cs'))
csproj = folder / 'ProcessHost.csproj'
ET.ElementTree(project).write(csproj, encoding='unicode')
status = run(['dotnet', 'build', csproj, '-c', 'Release', '-m:1', '--nologo'], folder / 'build.log')
if status:
raise RuntimeError(f'{version} harness build failed ({status}); see {folder / "build.log"}')
return folder / 'bin/Release/net10.0/ProcessHost.dll'
def certificates(source, target):
target.mkdir(parents=True, exist_ok=True)
files = list(source.glob('*'))
if not files:
raise RuntimeError('No public certificates at ' + str(source))
for file in files:
shutil.copy2(file, target / file.name)
def receipt(paths):
return [{'path': str(p), 'sha256': hashlib.sha256(p.read_bytes()).hexdigest(), 'bytes': p.stat().st_size} for p in sorted(set(paths)) if p.is_file()]
baseline = candidate = candidate_cli = None
try:
# Baseline builds only the portable harness against the exact published production DLLs.
baseline = build('baseline')
candidate = build('candidate')
candidate_cli = args.output / 'candidate-cli'
status = run(['dotnet', 'publish', args.candidate_root / CLI, '-c', 'Release', '-o', candidate_cli, '-m:1', '--nologo'], args.output / 'candidate-cli-build.log')
if status:
raise RuntimeError('Candidate CLI publish failed')
for name, client, server in [('baseline-client-to-baseline-server', baseline, baseline), ('baseline-client-to-candidate-server', baseline, candidate), ('candidate-client-to-baseline-server', candidate, baseline), ('candidate-client-to-candidate-server', candidate, candidate)]:
leg = args.output / name
leg.mkdir()
server_root = leg / 'server'
client_root = leg / 'shared'
server_root.mkdir()
client_root.mkdir()
status = run(['dotnet', client, 'prepare', client_root], leg / 'prepare.log')
if status:
matrix.append({'name': name, 'status': 'failed', 'stage': 'client-certificate-preparation'})
continue
certificates(client_root / 'client-pki/own/certs', server_root / 'server-pki/trusted/certs')
server_log = (leg / 'server.log').open('w')
command = ['dotnet', str(server), 'server', str(server_root)]
commands.append({'argv': command, 'log': str(leg / 'server.log')})
proc = subprocess.Popen(command, stdout=server_log, stderr=subprocess.STDOUT, env=env)
try:
deadline = time.monotonic() + 60
while not (server_root / 'ready.json').exists():
if proc.poll() is not None or time.monotonic() > deadline:
raise RuntimeError(f'{name}: server did not become ready; see server.log')
time.sleep(.1)
ready = json.loads((server_root / 'ready.json').read_text())
certificates(server_root / 'server-pki/own/certs', client_root / 'client-pki/trusted/certs')
status = run(['dotnet', client, 'client', client_root, ready['endpoint']], leg / 'shared.log')
result = json.loads((client_root / 'result.json').read_text())
history = [json.loads(x) for x in (server_root / 'history-requests.jsonl').read_text().splitlines()] if (server_root / 'history-requests.jsonl').exists() else []
result.update({'name': name, 'server': ready, 'status': 'passed' if status == 0 else 'failed', 'historyBackendRequests': history, 'certificateValidation': 'explicit trusted peers; server and Shared client autoaccept disabled', 'authentication': 'ephemeral API-key-shaped credential, exact fixture authenticator; real authority/ACL/method gates'})
write_json(leg / 'leg.json', result)
matrix.append(result)
except Exception as exc:
matrix.append({'name': name, 'status': 'failed', 'error': str(exc)})
finally:
(server_root / 'stop').touch()
try:
proc.wait(timeout=15)
except subprocess.TimeoutExpired:
proc.terminate()
proc.wait(timeout=10)
server_log.close()
image_info = subprocess.check_output(['docker', 'image', 'inspect', args.container_image, '--format', '{{json .}}'], text=True)
image_data = json.loads(image_info)
write_json(args.output / 'container-image.json', {k: image_data.get(k) for k in ('Id', 'RepoDigests', 'Architecture', 'Os')})
for server_name, server in [('baseline', baseline), ('candidate', candidate)]:
name = 'candidate-driver-cli-to-' + server_name + '-server'
leg = args.output / name
leg.mkdir()
shutil.copy2(HERE / 'container-leg.sh', leg / 'run.sh')
container_name = 'otopcua-sdk2-matrix-' + uuid.uuid4().hex[:12]
command = ['docker', 'run', '--rm', '--name', container_name, '--network', 'none', '-e', 'OPCUA_MATRIX_PASSWORD',
'-v', str(candidate.parent) + ':/candidate:ro', '-v', str(server.parent) + ':/server:ro',
'-v', str(candidate_cli) + ':/cli:ro', '-v', str(leg) + ':/task', args.container_image, 'bash', '/task/run.sh']
try:
status = run(command, leg / 'container.log')
finally:
subprocess.run(['docker', 'rm', '-f', container_name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if (leg / 'driver/result.json').exists():
result = json.loads((leg / 'driver/result.json').read_text())
else:
result = {'cases': [], 'error': 'Driver result missing; inspect preparation/container/server logs'}
for operation, expected in [('read', 'Value: 92'), ('raw-history', '25 values returned.'), ('event-history', '25 events returned.')]:
log = leg / ('cli-' + operation + '.log')
passed = log.exists() and expected in log.read_text()
result['cases'].append({'name': 'production-CLI-' + operation, 'status': 'passed' if passed else 'failed', 'log': str(log), 'expectedOutput': expected})
result.update({'name': name, 'status': 'passed' if status == 0 and all(x['status'] == 'passed' for x in result['cases']) else 'failed',
'container': container_name, 'network': 'none; all peers in task container, separate SDK processes',
'driverCertificateValidation': 'explicit trusted peers; autoaccept disabled', 'cliCertificateValidation': 'explicit exchanged peers; CLI always configures AutoAcceptCertificates=true (CommandBase.CreateConnectionSettings)',
'server': json.loads((leg / 'server/ready.json').read_text()) if (leg / 'server/ready.json').exists() else None})
write_json(leg / 'leg.json', result)
matrix.append(result)
finally:
paths = [p for p in (baseline, candidate, args.baseline_root / 'Directory.Packages.props', args.candidate_root / 'Directory.Packages.props', HERE / 'ProcessHost.cs', HERE / 'run.py', HERE / 'container-leg.sh') if p is not None]
for folder in (args.baseline_artifacts / 'host', args.baseline_artifacts / 'client', candidate.parent if candidate else None, candidate_cli):
if folder is not None:
paths.extend(folder.glob('Opc.Ua*.dll'))
paths.extend(folder.glob('*.deps.json'))
paths.extend(folder.glob('ZB.MOM.WW.OtOpcUa*.dll'))
paths.extend(folder.glob('OtOpcUa.Host.dll'))
paths.extend(folder.glob('*.runtimeconfig.json'))
write_json(args.output / 'artifact-hashes.json', receipt(paths))
write_json(args.output / 'commands.json', commands)
write_json(args.output / 'matrix.json', matrix)
return 1 if not matrix or any(x['status'] != 'passed' for x in matrix) else 0
if __name__ == '__main__':
raise SystemExit(main())
@@ -1,4 +1,3 @@
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
using System.Globalization;
using CliFx.Attributes;
using CliFx.Exceptions;
@@ -211,7 +210,7 @@ public class HistoryReadCommand : CommandBase
: StatusCode.IsBad(dv.StatusCode) ? "Bad"
: "Uncertain";
await console.Output.WriteLineAsync(
$"{dv.SourceTimestamp.ToString("O"),-35} {ValueConverter.ToClrObject(dv.WrappedValue)?.ToString() ?? "(none)",-15} {status,-10} " +
$"{dv.SourceTimestamp.ToString("O"),-35} {dv.Value?.ToString() ?? "(none)",-15} {status,-10} " +
$"{e.UpdateType,-8} {e.UserName ?? "",-20} {e.ModificationTime:O}");
}
@@ -245,7 +244,7 @@ public class HistoryReadCommand : CommandBase
: StatusCode.IsBad(dv.StatusCode) ? "Bad"
: "Uncertain";
await console.Output.WriteLineAsync(
$"{dv.SourceTimestamp.ToString("O"),-35} {ValueConverter.ToClrObject(dv.WrappedValue),-15} {status}");
$"{dv.SourceTimestamp.ToString("O"),-35} {dv.Value,-15} {status}");
}
await console.Output.WriteLineAsync();
@@ -277,25 +276,23 @@ 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;
clauses.Add(new SimpleAttributeOperand(type, new QualifiedName(field)) { AttributeId = Attributes.Value });
filter.SelectClauses.Add(new SimpleAttributeOperand(type, new QualifiedName(field)) { AttributeId = Attributes.Value });
}
filter.SelectClauses = clauses.ToArrayOf();
if (ofType is not null)
filter.WhereClause.Push(FilterOperator.OfType, Variant.From(ofType.Value));
filter.WhereClause.Push(FilterOperator.OfType, new LiteralOperand(new Variant(ofType)));
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) => ValueConverter.ToClrObject(field) switch
private static string FormatEventField(Variant field) => field.Value switch
{
null => "-",
byte[] bytes => Convert.ToHexString(bytes),
@@ -1,4 +1,3 @@
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
using System.Globalization;
using CliFx.Attributes;
using CliFx.Exceptions;
@@ -90,11 +89,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 List<DataValue>();
var updates = new DataValueCollection();
for (var i = 0; i < values.Count; i++)
{
var ts = ParseTime(times[i], "--time");
updates.Add(new DataValue(ValueConverter.ToVariant(ParseValue(values[i], Type)), StatusCodes.Good, ts, ts));
updates.Add(new DataValue(new Variant(ParseValue(values[i], Type)), StatusCodes.Good, ts, ts));
}
return new UpdateDataDetails
@@ -106,7 +105,7 @@ public class HistoryUpdateCommand : CommandBase
"replace" => PerformUpdateType.Replace,
_ => PerformUpdateType.Update
},
UpdateValues = updates.ToArrayOf()
UpdateValues = updates
};
}
case "delete-raw":
@@ -135,9 +134,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 List<DateTimeUtc>();
var req = new DateTimeCollection();
foreach (var t in times) req.Add(ParseTime(t, "--time"));
return new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = req.ToArrayOf() };
return new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = req };
}
}
}
@@ -239,8 +238,8 @@ public class HistoryUpdateCommand : CommandBase
/// <returns>The status name, or <c>0xXXXXXXXX</c>.</returns>
private static string StatusName(StatusCode code)
{
var name = code.SymbolicId;
if (string.IsNullOrEmpty(name)) name = new StatusCode(code.Code & 0xFFFF0000u).SymbolicId;
var name = StatusCodes.GetBrowseName(code.Code);
if (string.IsNullOrEmpty(name)) name = StatusCodes.GetBrowseName(code.Code & 0xFFFF0000u);
return string.IsNullOrEmpty(name) ? $"0x{code.Code:X8}" : name;
}
@@ -248,8 +247,8 @@ public class HistoryUpdateCommand : CommandBase
{
return details switch
{
UpdateDataDetails u => (u.UpdateValues.ToArray() ?? []).Select(v => v.SourceTimestamp.ToString("O")).ToList(),
DeleteAtTimeDetails d => (d.ReqTimes.ToArray() ?? []).Select(t => t.ToString("O")).ToList(),
UpdateDataDetails u => u.UpdateValues.Select(v => v.SourceTimestamp.ToString("O")).ToList(),
DeleteAtTimeDetails d => d.ReqTimes.Select(t => t.ToString("O")).ToList(),
DeleteRawModifiedDetails r => [$"{r.StartTime:O}..{r.EndTime:O}"],
_ => []
};
@@ -1,4 +1,3 @@
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Infrastructure;
@@ -60,12 +59,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} ({ex.StatusCode.SymbolicId})");
await console.Output.WriteLineAsync($"Status: {(StatusCode)ex.StatusCode} ({StatusCodes.GetBrowseName(ex.StatusCode)})");
return;
}
await console.Output.WriteLineAsync($"Node: {NodeId}");
await console.Output.WriteLineAsync($"Value: {ValueConverter.ToClrObject(value.WrappedValue)}");
await console.Output.WriteLineAsync($"Value: {value.Value}");
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,4 +1,3 @@
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
using System.Collections.Concurrent;
using System.Threading.Channels;
using CliFx.Attributes;
@@ -124,14 +123,14 @@ public class SubscribeCommand : CommandBase
try
{
var key = e.NodeId.ToString();
lastStatus[key] = (e.Value.StatusCode, DateTime.UtcNow, ValueConverter.ToClrObject(e.Value.WrappedValue));
lastStatus[key] = (e.Value.StatusCode, DateTime.UtcNow, e.Value.Value);
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)} = {ValueConverter.ToClrObject(e.Value.WrappedValue)} ({e.Value.StatusCode})";
$"[{e.Value.SourceTimestamp:O}] {displayNameByNodeId.GetValueOrDefault(key, key)} = {e.Value.Value} ({e.Value.StatusCode})";
outputChannel.Writer.TryWrite(line);
}
}
@@ -272,7 +271,7 @@ public class SubscribeCommand : CommandBase
var childPath = $"{parentPath}/{child.DisplayName}";
if (child.NodeClass == "Variable")
{
into.Add((nodeId.Value, childPath));
into.Add((nodeId, 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, ValueConverter.ToClrObject(currentValue.WrappedValue));
var typedValue = ValueConverter.ConvertValue(Value, currentValue.Value);
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.Value;
return result;
}
}
@@ -23,7 +23,7 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
? ClientStoragePaths.GetPkiPath()
: settings.CertificateStorePath;
var config = new ApplicationConfiguration(ClientTelemetry.Context)
var config = new ApplicationConfiguration
{
ApplicationName = "OtOpcUaClient",
ApplicationUri = "urn:localhost:OtOpcUaClient",
@@ -33,8 +33,7 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
ApplicationCertificate = new CertificateIdentifier
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(storePath, "own"),
SubjectName = "CN=OtOpcUaClient"
StorePath = Path.Combine(storePath, "own")
},
TrustedIssuerCertificates = new CertificateTrustList
{
@@ -59,28 +58,20 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
}
};
try
{
await config.ValidateAsync(ApplicationType.Client, ct);
await config.ValidateAsync(ApplicationType.Client, ct);
if (settings.SecurityMode != SecurityMode.None)
{
var app = new ApplicationInstance(ClientTelemetry.Context)
{
ApplicationName = "OtOpcUaClient",
ApplicationType = ApplicationType.Client,
ApplicationConfiguration = config
};
// Key size is configured separately; this argument is a lifetime in months.
// Keep the SDK creation default and reuse any valid certificate already in the store.
await app.CheckApplicationInstanceCertificatesAsync(false, ct: ct);
}
}
catch
if (settings.AutoAcceptCertificates)
config.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
if (settings.SecurityMode != SecurityMode.None)
{
try { (config.CertificateManager as IDisposable)?.Dispose(); }
catch (Exception ex) { Logger.Warning(ex, "Disposing certificate manager after configuration failure"); }
throw;
var app = new ApplicationInstance(ClientTelemetry.Context)
{
ApplicationName = "OtOpcUaClient",
ApplicationType = ApplicationType.Client,
ApplicationConfiguration = config
};
await app.CheckApplicationInstanceCertificatesAsync(false, 2048);
}
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(default, ct).ConfigureAwait(false);
var allEndpoints = await client.GetEndpointsAsync(null, ct).ConfigureAwait(false);
return EndpointSelector.SelectBest(allEndpoints.ToArray() ?? [], endpointUrl, requestedMode);
return EndpointSelector.SelectBest(allEndpoints, 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 ?? throw new InvalidOperationException("Endpoint has no URL."));
var serverUri = new Uri(best.EndpointUrl);
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,25 +12,21 @@ 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>
/// <param name="certificateManager">The per-connection certificate manager owned until session disposal.</param>
public DefaultSessionAdapter(Session session, IDisposable? certificateManager = null)
public DefaultSessionAdapter(Session session)
{
_session = session;
_certificateManager = certificateManager;
}
/// <inheritdoc />
public bool Connected => _session.Connected;
/// <inheritdoc />
public string SessionId => _session.SessionId.IsNull ? string.Empty : _session.SessionId.ToString();
public string SessionId => _session.SessionId?.ToString() ?? string.Empty;
/// <inheritdoc />
public string SessionName => _session.SessionName ?? string.Empty;
@@ -39,7 +35,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;
@@ -76,13 +72,13 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
Value = value
};
ArrayOf<WriteValue> writeCollection = [writeValue];
var writeCollection = new WriteValueCollection { 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.Count == 0)
if (response.Results == null || response.Results.Count == 0)
{
var serviceResult = response.ResponseHeader?.ServiceResult.Code ?? StatusCodes.BadUnexpectedError;
throw new ServiceResultException(serviceResult,
@@ -93,7 +89,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
}
/// <inheritdoc />
public async Task<(byte[]? ContinuationPoint, ArrayOf<ReferenceDescription> References)> BrowseAsync(
public async Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync(
NodeId nodeId, uint nodeClassMask, CancellationToken ct)
{
var (_, continuationPoint, references) = await _session.BrowseAsync(
@@ -104,25 +100,19 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
BrowseDirection.Forward,
ReferenceTypeIds.HierarchicalReferences,
true,
nodeClassMask, ct);
nodeClassMask);
return (continuationPoint.IsNull ? null : continuationPoint.ToArray(), references);
return (continuationPoint, references ?? []);
}
/// <inheritdoc />
public async Task<(byte[]? ContinuationPoint, ArrayOf<ReferenceDescription> References)> BrowseNextAsync(
public async Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection 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, 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);
var (_, nextCp, nextRefs) = await _session.BrowseNextAsync(null, false, continuationPoint, ct);
return (nextCp, nextRefs ?? []);
}
/// <inheritdoc />
@@ -130,16 +120,16 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
{
var response = await _session.HistoryUpdateAsync(
null,
[new ExtensionObject(details)],
new ExtensionObjectCollection { new ExtensionObject(details) },
ct).ConfigureAwait(false);
var statuses = new List<StatusCode>();
var ops = new List<IReadOnlyList<StatusCode>>();
if (!response.Results.IsEmpty)
if (response.Results != null)
foreach (var r in response.Results)
{
statuses.Add(r.StatusCode);
ops.Add(r.OperationResults.ToArray() ?? []);
ops.Add(r.OperationResults?.ToArray() ?? Array.Empty<StatusCode>());
}
return new Models.HistoryUpdateOutcome(response.ResponseHeader.ServiceResult, statuses, ops);
@@ -162,10 +152,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<DataValue>(
return await HistoryReadPaging.ReadAllAsync(
(cp, release, token) => ReadHistoryPageAsync(details, nodeId, cp, release, TimestampsToReturn.Source, token),
result => result.HistoryData.TryGetValue(out HistoryData? historyData)
? historyData.DataValues.ToArray() ?? []
result => result.HistoryData is ExtensionObject { Body: HistoryData historyData }
? historyData.DataValues
: [],
maxValues, nodeId, ct).ConfigureAwait(false);
}
@@ -203,21 +193,21 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
return await HistoryReadPaging.ReadAllAsync(
(cp, release, token) => ReadHistoryPageAsync(details, nodeId, cp, release, TimestampsToReturn.Both, token),
result => result.HistoryData.TryGetValue(out HistoryEvent? historyEvent)
? (historyEvent.Events.ToArray() ?? []).Select(e => new Models.HistoryEventEntry(e.EventFields.ToArray() ?? []))
result => result.HistoryData is ExtensionObject { Body: HistoryEvent historyEvent }
? historyEvent.Events.Select(e => new Models.HistoryEventEntry(e.EventFields?.ToArray() ?? []))
: [],
maxEvents, nodeId, ct).ConfigureAwait(false);
}
private static IEnumerable<Models.HistoryModifiedEntry> ToModifiedEntries(HistoryReadResult result)
{
if (!result.HistoryData.TryGetValue(out HistoryModifiedData? modified)) yield break;
if (result.HistoryData is not ExtensionObject { Body: HistoryModifiedData modified }) yield break;
for (var i = 0; i < modified.DataValues.Count; i++)
{
var info = i < modified.ModificationInfos.Count ? modified.ModificationInfos[i] : null;
yield return new Models.HistoryModifiedEntry(
modified.DataValues[i],
info?.ModificationTime.ToDateTime() ?? DateTime.MinValue,
info?.ModificationTime ?? DateTime.MinValue,
info?.UpdateType ?? default,
info?.UserName);
}
@@ -228,10 +218,10 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
HistoryReadDetails details, NodeId nodeId, byte[]? continuationPoint, bool release,
TimestampsToReturn timestamps, CancellationToken ct)
{
ArrayOf<HistoryReadValueId> nodesToRead =
[
new HistoryReadValueId { NodeId = nodeId, ContinuationPoint = ByteString.From(continuationPoint) }
];
var nodesToRead = new HistoryReadValueIdCollection
{
new HistoryReadValueId { NodeId = nodeId, ContinuationPoint = continuationPoint }
};
// The async overload honours the token and does not block the caller's thread (the UI dispatcher for
// client.ui consumers).
@@ -254,7 +244,10 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
AggregateType = [aggregateId]
};
ArrayOf<HistoryReadValueId> nodesToRead = [new HistoryReadValueId { NodeId = nodeId }];
var nodesToRead = new HistoryReadValueIdCollection
{
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.
@@ -269,23 +262,13 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
var results = response.Results;
var allValues = new List<DataValue>();
if (results.Count > 0)
if (results != null && results.Count > 0)
{
var result = results[0];
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);
}
HistoryReadStatus.ThrowIfRefused(result.StatusCode, nodeId);
if (result.HistoryData is ExtensionObject ext &&
ext.Body is HistoryData historyData)
allValues.AddRange(historyData.DataValues);
}
return allValues;
@@ -339,7 +322,6 @@ 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));
@@ -348,8 +330,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
{
}
try { _session.Dispose(); }
finally { _certificateManager?.Dispose(); }
_session.Dispose();
}
/// <inheritdoc />
@@ -358,20 +339,20 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
{
var result = await _session.CallAsync(
null,
new CallMethodRequest[]
new CallMethodRequestCollection
{
new()
{
ObjectId = objectId,
MethodId = methodId,
InputArguments = inputArguments.Select(ValueConverter.ToVariant).ToArrayOf()
InputArguments = new VariantCollection(inputArguments.Select(a => new Variant(a)))
}
},
ct);
// An empty Results collection paired with a service fault must surface as
// a ServiceResultException, not an IndexOutOfRangeException from Results[0].
if (result.Results.Count == 0)
if (result.Results == null || result.Results.Count == 0)
{
var serviceResult = result.ResponseHeader?.ServiceResult.Code ?? StatusCodes.BadUnexpectedError;
throw new ServiceResultException(serviceResult,
@@ -382,7 +363,6 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
if (StatusCode.IsBad(callResult.StatusCode))
throw new ServiceResultException(callResult.StatusCode);
return callResult.OutputArguments.IsNull ? null
: (callResult.OutputArguments.ToArray() ?? []).Select(v => ValueConverter.ToClrObject(v)!).ToList();
return callResult.OutputArguments?.Select(v => v.Value).ToList();
}
}
@@ -30,13 +30,13 @@ internal sealed class DefaultSessionFactory : ISessionFactory
sessionName,
sessionTimeoutMs,
identity,
default,
null,
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, config.CertificateManager as IDisposable);
return new DefaultSessionAdapter(session);
}
}
@@ -14,8 +14,6 @@ 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.
@@ -49,7 +47,7 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
item.Notification += (_, e) =>
{
if (Volatile.Read(ref _finishing) == 0 && e.NotificationValue is MonitoredItemNotification notification)
if (e.NotificationValue is MonitoredItemNotification notification)
onDataChange(nodeId.ToString(), notification.Value);
};
@@ -92,7 +90,7 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
item.Notification += (_, e) =>
{
if (Volatile.Read(ref _finishing) == 0 && e.NotificationValue is EventFieldList eventFields) onEvent(eventFields);
if (e.NotificationValue is EventFieldList eventFields) onEvent(eventFields);
};
_subscription.AddItem(item);
@@ -114,28 +112,19 @@ 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
{
try { await _subscription.DeleteAsync(true, ct).ConfigureAwait(false); }
catch (Exception ex) { Logger.Warning(ex, "Error deleting subscription"); }
// 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"); }
}
// Forward the caller's token so the delete can be cancelled.
await _subscription.DeleteAsync(true, ct);
}
finally { _finished.TrySetResult(); }
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();
}
private async Task DetachAsync(CancellationToken ct)
@@ -156,6 +145,21 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
/// </summary>
public void Dispose()
{
DisposeBridge.Run(DeleteAsync);
// 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();
}
}
@@ -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, ArrayOf<ReferenceDescription> References)> BrowseAsync(
Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync(
NodeId nodeId, uint nodeClassMask = 0, CancellationToken ct = default);
/// <summary>
@@ -87,15 +87,9 @@ 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, ArrayOf<ReferenceDescription> References)> BrowseNextAsync(
Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection 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.IsNull ? null : result.ContinuationPoint.ToArray();
continuationPoint = result.ContinuationPoint;
if (continuationPoint is not { Length: > 0 }) return Trim(all, maxValues);
if (all.Count >= maxValues) break;
}
}
catch
catch (OperationCanceledException)
{
// A refused/failed/cancelled continuation still leaves the last cursor owned by this read.
// Best-effort release must preserve the original operation failure.
// Cancelled between pages (or during page N+1) with a cursor outstanding: release it rather than leave it
// occupying one of the session's continuation-point slots, then surface the cancellation.
if (continuationPoint is { Length: > 0 }) await ReleaseAsync(readPage, continuationPoint, nodeId);
throw;
}
@@ -81,12 +81,12 @@ internal static class HistoryReadPaging
return all;
}
internal static async Task ReleaseAsync(PageReader readPage, byte[] continuationPoint, NodeId nodeId)
private static async Task ReleaseAsync(PageReader readPage, byte[] continuationPoint, NodeId nodeId)
{
try
{
using var cts = new CancellationTokenSource(ReleaseTimeout);
await readPage(continuationPoint, true, cts.Token).WaitAsync(cts.Token).ConfigureAwait(false);
await readPage(continuationPoint, true, 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: {status.ToString()} (0x{status.Code:X8}).");
$"HistoryRead of {nodeId} failed: {StatusCodes.GetBrowseName(status.Code)} (0x{status.Code:X8}).");
}
}
@@ -1,5 +1,3 @@
using Opc.Ua;
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
/// <summary>
@@ -8,49 +6,6 @@ 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) || ValueConverter.ToClrObject(currentDataValue.WrappedValue) == null)
if (!StatusCode.IsGood(currentDataValue.StatusCode) || currentDataValue.Value == 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, ValueConverter.ToClrObject(currentDataValue.WrappedValue));
typedValue = ValueConverter.ConvertValue(rawString, currentDataValue.Value);
}
var dataValue = new DataValue(ValueConverter.ToVariant(typedValue));
var dataValue = new DataValue(new Variant(typedValue));
return await _session!.WriteValueAsync(nodeId, dataValue, ct);
}
@@ -242,39 +242,22 @@ public sealed class OpcUaClientService : IOpcUaClientService
var nodeClassMask = (uint)NodeClass.Object | (uint)NodeClass.Variable | (uint)NodeClass.Method;
var results = new List<BrowseResult>();
// A cursor belongs to the session that issued it, including while failover swaps _session.
var session = _session!;
byte[]? continuationPoint = null;
try
{
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));
var (continuationPoint, references) = await _session!.BrowseAsync(startNode, nodeClassMask, ct);
if (continuationPoint is not { Length: > 0 } pending) break;
ct.ThrowIfCancellationRequested();
(continuationPoint, references) = await session.BrowseNextAsync(pending, ct);
}
}
finally
while (references.Count > 0)
{
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"); }
}
// 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));
if (continuationPoint is not { Length: > 0 } pending) break;
(continuationPoint, references) = await _session.BrowseNextAsync(pending, ct);
}
return results;
@@ -310,7 +293,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
}
var handle = await subscription.AddDataChangeMonitoredItemAsync(
nodeId, intervalMs, DataCallback(session, subscription), ct);
nodeId, intervalMs, OnDataChangeNotification, ct);
lock (_subscriptionLock)
{
@@ -394,7 +377,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
var filter = CreateAlarmEventFilter();
await subscription.AddEventMonitoredItemAsync(
monitorNode, intervalMs, filter, AlarmCallback(session, subscription), ct);
monitorNode, intervalMs, filter, OnAlarmEventNotification, ct);
lock (_subscriptionLock)
{
@@ -700,15 +683,15 @@ public sealed class OpcUaClientService : IOpcUaClientService
var redundancySupportValue =
await _session!.ReadValueAsync(VariableIds.Server_ServerRedundancy_RedundancySupport, ct);
RedundancySupport redundancySupport;
if (StatusCode.IsGood(redundancySupportValue.StatusCode) && ValueConverter.ToClrObject(redundancySupportValue.WrappedValue) != null)
redundancySupport = (RedundancySupport)Convert.ToInt32(ValueConverter.ToClrObject(redundancySupportValue.WrappedValue));
if (StatusCode.IsGood(redundancySupportValue.StatusCode) && redundancySupportValue.Value != null)
redundancySupport = (RedundancySupport)Convert.ToInt32(redundancySupportValue.Value);
else
redundancySupport = RedundancySupport.None;
var redundancyMode = redundancySupport.ToString();
var serviceLevelValue = await _session.ReadValueAsync(VariableIds.Server_ServiceLevel, ct);
var serviceLevel = StatusCode.IsGood(serviceLevelValue.StatusCode) && ValueConverter.ToClrObject(serviceLevelValue.WrappedValue) != null
? Convert.ToByte(ValueConverter.ToClrObject(serviceLevelValue.WrappedValue))
var serviceLevel = StatusCode.IsGood(serviceLevelValue.StatusCode) && serviceLevelValue.Value != null
? Convert.ToByte(serviceLevelValue.Value)
: (byte)0;
string[] serverUris = [];
@@ -716,7 +699,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
{
var serverUriArrayValue =
await _session.ReadValueAsync(VariableIds.Server_ServerRedundancy_ServerUriArray, ct);
if (ValueConverter.ToClrObject(serverUriArrayValue.WrappedValue) is string[] uris)
if (serverUriArrayValue.Value is string[] uris)
serverUris = uris;
}
catch
@@ -728,7 +711,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
try
{
var serverArrayValue = await _session.ReadValueAsync(VariableIds.Server_ServerArray, ct);
if (ValueConverter.ToClrObject(serverArrayValue.WrappedValue) is string[] serverArray && serverArray.Length > 0)
if (serverArrayValue.Value is string[] serverArray && serverArray.Length > 0)
applicationUri = serverArray[0];
}
catch
@@ -783,26 +766,16 @@ public sealed class OpcUaClientService : IOpcUaClientService
};
var config = await _configFactory.CreateAsync(effectiveSettings, ct);
try
{
var requestedMode = SecurityModeMapper.ToMessageSecurityMode(settings.SecurityMode);
var endpoint = await _endpointDiscovery.SelectEndpointAsync(config, endpointUrl, requestedMode, ct);
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);
}
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;
}
var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000);
return await _sessionFactory.CreateSessionAsync(config, endpoint, "OtOpcUaClient", sessionTimeoutMs, identity,
ct);
}
/// <summary>A bad keep-alive from the session of connection <paramref name="epoch"/>.</summary>
@@ -989,7 +962,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
}
var handle = await dataSubscription.AddDataChangeMonitoredItemAsync(
nodeId, intervalMs, DataCallback(session, dataSubscription), CancellationToken.None);
nodeId, intervalMs, OnDataChangeNotification, CancellationToken.None);
lock (_subscriptionLock)
{
@@ -1012,7 +985,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
lock (_subscriptionLock) _alarmSubscription = replayedAlarms;
var filter = CreateAlarmEventFilter();
await replayedAlarms.AddEventMonitoredItemAsync(
monitorNode, intervalMs, filter, AlarmCallback(session, replayedAlarms), CancellationToken.None);
monitorNode, intervalMs, filter, OnAlarmEventNotification, CancellationToken.None);
lock (_subscriptionLock)
{
@@ -1026,25 +999,6 @@ 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));
@@ -1053,28 +1007,28 @@ public sealed class OpcUaClientService : IOpcUaClientService
private void OnAlarmEventNotification(EventFieldList eventFields)
{
var fields = eventFields.EventFields;
if (fields.Count < 1)
if (fields == null || fields.Count < 1)
return;
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 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 time = DateTime.MinValue;
if (fields.Count > 3 && ValueConverter.ToClrObject(fields[3]) != null)
if (fields.Count > 3 && fields[3].Value != null)
{
if (ValueConverter.ToClrObject(fields[3]) is DateTime dt)
if (fields[3].Value is DateTime dt)
time = dt;
else if (DateTime.TryParse(ValueConverter.ToClrObject(fields[3])?.ToString(), out var parsed))
else if (DateTime.TryParse(fields[3].Value.ToString(), out var parsed))
time = parsed;
}
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;
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;
// Try standard OPC UA ActiveState/AckedState fields first
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;
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;
var ackedState = ackedField ?? false;
var activeState = activeField ?? false;
@@ -1099,25 +1053,39 @@ public sealed class OpcUaClientService : IOpcUaClientService
/// <returns>The populated <see cref="EventFilter"/>.</returns>
internal static EventFilter CreateAlarmEventFilter()
{
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")] },
]
};
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);
return filter;
}
@@ -8,6 +8,7 @@
</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 = statusCode.SymbolicId;
var name = StatusCodes.GetBrowseName(code);
if (!string.IsNullOrEmpty(name))
return $"{hex} ({name})";
@@ -24,7 +24,7 @@ internal static class StatusCodeFormatter
var mainCode = code & 0xFFFF0000;
if (mainCode != code)
{
name = new StatusCode(mainCode).SymbolicId;
name = StatusCodes.GetBrowseName(mainCode);
if (!string.IsNullOrEmpty(name))
return $"{hex} ({name})";
}
@@ -109,7 +109,7 @@ public partial class AlarmsViewModel : ObservableObject
{
try
{
NodeId? sourceNodeId = string.IsNullOrWhiteSpace(MonitoredNodeIdText)
var sourceNodeId = string.IsNullOrWhiteSpace(MonitoredNodeIdText)
? null
: NodeId.Parse(MonitoredNodeIdText);
@@ -275,7 +275,7 @@ public partial class AlarmsViewModel : ObservableObject
try
{
NodeId? nodeId = string.IsNullOrWhiteSpace(sourceNodeId)
var nodeId = string.IsNullOrWhiteSpace(sourceNodeId)
? null
: NodeId.Parse(sourceNodeId);
@@ -1,4 +1,3 @@
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -98,7 +97,7 @@ public partial class HistoryViewModel : ObservableObject
{
foreach (var dv in values)
Results.Add(new HistoryValueViewModel(
Helpers.ValueFormatter.Format(ValueConverter.ToClrObject(dv.WrappedValue)),
Helpers.ValueFormatter.Format(dv.Value),
Helpers.StatusCodeFormatter.Format(dv.StatusCode),
dv.SourceTimestamp.ToString("O"),
dv.ServerTimestamp.ToString("O")));
@@ -1,4 +1,3 @@
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Opc.Ua;
@@ -77,7 +76,7 @@ public partial class ReadWriteViewModel : ObservableObject
_dispatcher.Post(() =>
{
CurrentValue = Helpers.ValueFormatter.Format(ValueConverter.ToClrObject(dataValue.WrappedValue));
CurrentValue = Helpers.ValueFormatter.Format(dataValue.Value);
CurrentStatus = Helpers.StatusCodeFormatter.Format(dataValue.StatusCode);
SourceTimestamp = dataValue.SourceTimestamp.ToString("O");
ServerTimestamp = dataValue.ServerTimestamp.ToString("O");
@@ -1,4 +1,3 @@
using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -76,7 +75,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(ValueConverter.ToClrObject(e.Value.WrappedValue));
item.Value = Helpers.ValueFormatter.Format(e.Value.Value);
item.Status = Helpers.StatusCodeFormatter.Format(e.Value.StatusCode);
item.Timestamp = e.Value.SourceTimestamp.ToString("O");
}
@@ -282,7 +281,7 @@ public partial class SubscriptionsViewModel : ObservableObject
// Read current value to determine target type
var currentDataValue = await _service.ReadValueAsync(nodeId);
var currentValue = ValueConverter.ToClrObject(currentDataValue.WrappedValue);
var currentValue = currentDataValue.Value;
// Try parsing to the target type before writing
try
@@ -27,11 +27,10 @@ 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)
.GetProperties(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.PropertyType == typeof(NodeId) && f.Name.StartsWith("AggregateFunction_", StringComparison.Ordinal))
.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.FieldType == typeof(NodeId) && f.Name.StartsWith("AggregateFunction_", StringComparison.Ordinal))
.Select(f => (NodeId)f.GetValue(null)!)
.ToArray();
@@ -57,10 +56,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 DiagnosticsNodeManager diagnostics) return null;
if (server?.DiagnosticsNodeManager is not CustomNodeManager2 diagnostics) return null;
var advertised = new List<NodeId>();
lock (SyncGate)
lock (diagnostics.Lock)
{
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 { } nodeId && ByNodeId.TryGetValue(nodeId, out var aggregate) ? aggregate : null;
aggregateNodeId is not null && ByNodeId.TryGetValue(aggregateNodeId, out var aggregate) ? aggregate : null;
}
@@ -1,63 +0,0 @@
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);
}
}
@@ -1,382 +0,0 @@
/* ========================================================================
* 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;
}
}
}
@@ -1,247 +0,0 @@
/* ========================================================================
* 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;
}
}
}
@@ -1,33 +0,0 @@
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;
}
}
}
@@ -1,278 +0,0 @@
/* ========================================================================
* 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;
}
}
}
@@ -1,36 +0,0 @@
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);
}
@@ -1,505 +0,0 @@
/* ========================================================================
* 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;
}
}
}
@@ -1,19 +0,0 @@
# 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.
@@ -1,355 +0,0 @@
/* ========================================================================
* 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;
}
}
}
@@ -1,250 +0,0 @@
/* ========================================================================
* 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;
}
}
}
@@ -1,342 +0,0 @@
/* ========================================================================
* 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;
}
}
}
@@ -1,42 +0,0 @@
[
{
"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"
}
]
@@ -1,87 +0,0 @@
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.Code, atUtc, atUtc);
var placeholder = new DataValueSnapshot(null, StatusCodes.BadBoundNotFound, atUtc, atUtc);
Placeholders.AddOrUpdate(placeholder, PlaceholderMark);
return placeholder;
}
@@ -1,4 +1,3 @@
using Opc.Ua;
using Opc.Ua.Server;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
@@ -44,8 +43,8 @@ internal interface IHistoryContinuationStore
/// <summary>
/// Production <see cref="IHistoryContinuationStore"/> backed by the OPC UA SDK's per-session
/// history-continuation store (<see cref="ISessionContinuationPoints.SaveHistory"/> /
/// <see cref="ISessionContinuationPoints.RestoreHistory"/>). Using the SDK store gives us, for free:
/// history-continuation store (<see cref="ISession.SaveHistoryContinuationPoint"/> /
/// <see cref="ISession.RestoreHistoryContinuationPoint"/>). 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>
@@ -76,7 +75,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.ContinuationPoints.SaveHistory(new SessionHistoryPoint(id, state));
session.SaveHistoryContinuationPoint(id, state);
return id.ToByteArray();
}
@@ -86,22 +85,13 @@ 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.
using var point = session.ContinuationPoints.RestoreHistory(ByteString.From(continuationPoint));
return (point as SessionHistoryPoint)?.State;
return session.RestoreHistoryContinuationPoint(continuationPoint) as HistoryContinuationState;
}
/// <inheritdoc />
public void Release(ISession? session, byte[] continuationPoint) =>
// Restoring removes the slot; we discard the value. Null session / unknown point ⇒ no-op.
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() { }
}
session?.RestoreHistoryContinuationPoint(continuationPoint);
}
/// <summary>
@@ -73,8 +73,6 @@ 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,
@@ -89,9 +87,7 @@ internal sealed record HistoryContinuationState(
long EmptyFromTicks = 0,
long EmptyToTicks = 0,
HistoryEndBound? PendingEndBound = null,
bool WithinTieCluster = false,
string? BoundNodeKey = null,
long AddressSpaceEpoch = -1)
bool WithinTieCluster = false)
{
/// <summary>The walk-back hint this cursor carries.</summary>
public ReverseWalkHint WalkHint => new(SegmentWidthTicks, EmptyFromTicks, EmptyToTicks);
@@ -147,8 +143,9 @@ internal static class HistoryPaging
if (server?.DiagnosticsNodeManager is not DiagnosticsNodeManager diagnostics) return false;
try
{
lock (diagnostics.Lock)
{
var capabilities = diagnostics.GetDefaultHistoryCapabilitiesAsync().AsTask().GetAwaiter().GetResult();
var capabilities = diagnostics.GetDefaultHistoryCapabilities();
if (capabilities?.MaxReturnDataValues is not { } property) return false;
property.Value = (uint)Math.Max(0, maxRawValuesPerRead);
property.ClearChangeMasks(diagnostics.SystemContext, false);
@@ -2,7 +2,6 @@ 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;
@@ -173,8 +172,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
private readonly ILogger<OpcUaApplicationHost> _logger;
private ApplicationInstance? _application;
private StandardServer? _server;
private readonly ITelemetryContext _telemetry = DefaultTelemetry.Create(_ => { });
private readonly List<IUserTokenAuthenticator> _identityAuthenticators = [];
private ImpersonateEventHandler? _impersonateHandler;
/// <summary>Initializes a new instance of the OPC UA application host.</summary>
/// <param name="options">The host configuration options.</param>
@@ -202,67 +200,52 @@ 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(_telemetry)
_application = new ApplicationInstance(DefaultTelemetry.Create(_ => { }))
{
ApplicationName = _options.ApplicationName,
ApplicationType = ApplicationType.Server,
ConfigSectionName = "OtOpcUa",
};
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;
}
_ = await BuildConfigurationAsync(cancellationToken);
await EnsureApplicationCertificateAsync(cancellationToken).ConfigureAwait(false);
await _application.StartAsync(server).ConfigureAwait(false);
AttachUserAuthenticator();
PopulateServerArray();
_logger.LogInformation("OPC UA server started on opc.tcp://{Host}:{Port}",
_options.PublicHostname, _options.OpcUaPort);
}
private void AttachUserAuthenticator(OtOpcUaSdkServer server)
/// <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 &lt;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()
{
foreach (var type in new[] { UserTokenType.Anonymous, UserTokenType.UserName })
var sessionManager = _server?.CurrentInstance?.SessionManager;
if (sessionManager is null)
{
var adapter = new ApplicationTokenAuthenticator(this, type);
_identityAuthenticators.Add(adapter);
_logger.LogWarning("OpcUaApplicationHost: SessionManager unavailable after Start; UserName auth disabled");
return;
}
server.ConfigureIdentityAuthenticators(_identityAuthenticators);
}
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));
}
_impersonateHandler = OnImpersonateUser;
sessionManager.ImpersonateUser += _impersonateHandler;
}
/// <summary>
@@ -315,18 +298,46 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
if (!string.IsNullOrWhiteSpace(peer) && !uris.Contains(peer))
uris.Add(peer);
}
serverObject.ServerArray!.Value = (ArrayOf<string>)uris;
serverObject.ServerArray.Value = uris.ToArray();
}
}
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>
/// 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.
/// <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.)
/// </remarks>
/// <param name="authenticator">The user authenticator to validate credentials.</param>
/// <param name="args">The impersonation event arguments to process.</param>
@@ -340,21 +351,14 @@ 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.UserIdentityTokenHandler.Token is AnonymousIdentityToken anonymousToken)
if (args.NewIdentity is AnonymousIdentityToken anonymousToken)
{
await HandleAnonymousImpersonationAsync(authenticator, args, logger, allowAnonymous, anonymousToken, cancellationToken).ConfigureAwait(false);
HandleAnonymousImpersonation(authenticator, args, logger, allowAnonymous, anonymousToken);
return;
}
if (args.UserIdentityTokenHandler.Token is not UserNameIdentityToken token)
if (args.NewIdentity is not UserNameIdentityToken token)
{
// X509 tokens — let the SDK's default validation stand.
return;
@@ -365,27 +369,29 @@ 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 = (args.UserIdentityTokenHandler as UserNameIdentityTokenHandler)?.DecryptedPassword;
var decryptedBytes = token.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 = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, "UserName token decryption failed");
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
"UserName token decryption failed");
return;
}
OpcUaUserAuthResult result;
try
{
result = await authenticator
.AuthenticateUserNameAsync(token.UserName ?? string.Empty, password, remoteEndpoint, cancellationToken)
.ConfigureAwait(false);
result = authenticator
.AuthenticateUserNameAsync(token.UserName ?? string.Empty, password, remoteEndpoint, CancellationToken.None)
.GetAwaiter().GetResult();
}
catch (Exception ex)
{
logger.LogError(ex, "OpcUaApplicationHost: UserName authenticator threw for {User}", token.UserName);
args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, "Authentication failed");
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
"Authentication failed");
return;
}
@@ -393,7 +399,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
{
logger.LogInformation("OpcUaApplicationHost: UserName auth denied for {User}: {Error}",
token.UserName, result.Error);
args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, result.Error ?? "Invalid credentials");
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
result.Error ?? "Invalid credentials");
return;
}
@@ -414,25 +421,28 @@ 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 async ValueTask HandleAnonymousImpersonationAsync(
private static void HandleAnonymousImpersonation(
IOpcUaUserAuthenticator authenticator,
ImpersonateEventArgs args,
ILogger logger,
bool allowAnonymous,
AnonymousIdentityToken token, CancellationToken cancellationToken)
AnonymousIdentityToken token)
{
if (!allowAnonymous)
{
logger.LogInformation(
"OpcUaApplicationHost: anonymous session rejected (OpcUa:AllowAnonymous is false)");
args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, "Anonymous access is disabled on this server.");
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
"Anonymous access is disabled on this server.");
return;
}
OpcUaUserAuthResult result;
try
{
result = await authenticator.AuthenticateAnonymousAsync(cancellationToken).ConfigureAwait(false);
// Same synchronous bridge, and for the same irreducible reason, as the UserName path above.
result = authenticator.AuthenticateAnonymousAsync(CancellationToken.None)
.GetAwaiter().GetResult();
}
catch (Exception ex)
{
@@ -440,14 +450,16 @@ 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 = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, "Anonymous access is unavailable.");
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
"Anonymous access is unavailable.");
return;
}
if (!result.Success)
{
logger.LogInformation("OpcUaApplicationHost: anonymous session denied: {Error}", result.Error);
args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, result.Error ?? "Anonymous access is denied.");
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
result.Error ?? "Anonymous access is denied.");
return;
}
@@ -488,14 +500,20 @@ 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);
serverConfig.SecurityPolicies = BuildSecurityPolicies(_options.EnabledSecurityProfiles).ToArray();
serverConfig.UserTokenPolicies = BuildUserTokenPolicies(_options.AllowAnonymous).ToArray();
foreach (var policy in BuildSecurityPolicies(_options.EnabledSecurityProfiles))
{
serverConfig.SecurityPolicies.Add(policy);
}
foreach (var token in BuildUserTokenPolicies(_options.AllowAnonymous))
{
serverConfig.UserTokenPolicies.Add(token);
}
var config = new ApplicationConfiguration(_telemetry)
var config = new ApplicationConfiguration
{
ApplicationName = _options.ApplicationName,
ApplicationUri = _options.ApplicationUri,
@@ -519,10 +537,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
ClientConfiguration = new ClientConfiguration(),
};
// 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);
_application!.ApplicationConfiguration = config;
return config;
}
@@ -643,14 +659,23 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
// Authenticators remain installed until the SDK closes every listener and disposes its registry.
if (_application is { } application)
try
{
_application = null;
try { await application.DisposeAsync().ConfigureAwait(false); }
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: disposal threw"); }
// 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"); }
}
_identityAuthenticators.Clear();
_server = null;
}
}
File diff suppressed because it is too large Load Diff
@@ -1,8 +1,6 @@
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;
@@ -24,62 +22,17 @@ 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)
: base(DefaultTelemetry.Create(_ => { })) => _nodeManagerLogger = nodeManagerLogger;
public OtOpcUaSdkServer(ILogger<OtOpcUaNodeManager>? nodeManagerLogger = null) => _nodeManagerLogger = nodeManagerLogger;
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 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);
/// <summary>The custom node manager once <c>StartAsync</c> has called
/// <see cref="CreateMasterNodeManager"/>. Null until the SDK has bootstrapped.</summary>
@@ -271,47 +224,28 @@ public sealed class OtOpcUaSdkServer : StandardServer
return true;
}
// 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.
// 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.
/// <inheritdoc />
protected override async ValueTask<AggregateManager> CreateAggregateManagerAsync(
IServerInternal server, ApplicationConfiguration configuration, CancellationToken cancellationToken = default)
protected override AggregateManager CreateAggregateManager(
IServerInternal server, ApplicationConfiguration configuration)
{
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);
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;
}
/// <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="IServerContext.UpdateServerDiagnostics"/> so concurrent diagnostics scans
/// guarded by <see cref="IServerInternal.DiagnosticsLock"/> 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
{
_serverInternal.UpdateServerDiagnostics(_ =>
lock (_serverInternal.DiagnosticsLock)
{
node.Value = serviceLevel;
node.Timestamp = DateTime.UtcNow;
node.StatusCode = StatusCodes.Good;
node.ClearChangeMasks(_serverInternal.DefaultSystemContext, includeChildren: false);
});
}
}
catch (Exception ex)
{
@@ -1,40 +0,0 @@
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.TryGetValue(out string identifier) || identifier.Length == 0)
if (nodeId?.Identifier is not 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 may augment identities with mandatory and dynamically mapped roles. This subtype
/// preserves the application identity when those roles are added, so a method
/// (<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
/// 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 : RoleBasedIdentity
public sealed class RoleCarryingUserIdentity : UserIdentity
{
/// <summary>
/// Initializes a new instance, building the base identity from the same UserName token the
@@ -35,14 +35,7 @@ public sealed class RoleCarryingUserIdentity : RoleBasedIdentity
/// <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)
: 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)
: base(token)
{
ArgumentNullException.ThrowIfNull(roles);
// Freeze to a defensive copy so a caller mutating the source list can never retroactively
@@ -53,13 +46,8 @@ public sealed class RoleCarryingUserIdentity : RoleBasedIdentity
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 new IReadOnlyList<string> Roles { get; }
public IReadOnlyList<string> Roles { get; }
/// <summary>
/// The ACL principal this session authenticates as — the name <c>NodeAcl</c> rows key on, and what
@@ -0,0 +1,106 @@
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,7 +5,13 @@
</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>
@@ -23,9 +29,6 @@
<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!.Value.IdentifierAsString.ShouldBe("AlarmSource");
fakeService.SubscribeAlarmsCalls[0].SourceNodeId!.Identifier.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]!.Value.IdentifierAsString.ShouldBe("StartNode");
fakeService.BrowseNodeIds[0]!.Identifier.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.IdentifierAsString.ShouldBe("HistNode");
fakeService.HistoryReadRawCalls[0].NodeId.Identifier.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.ToArray()!.Select(c => c.BrowsePath.ToArray()!.ShouldHaveSingleItem().Name)
call.Filter.SelectClauses.Select(c => c.BrowsePath.ShouldHaveSingleItem().Name)
.ShouldBe(["EventId", "EventType", "Time", "SourceName", "Severity", "Message", "ConditionName", "SourceNode"]);
call.Filter.SelectClauses.ToArray()!.Single(c => c.BrowsePath[0].Name == "ConditionName").TypeDefinitionId
call.Filter.SelectClauses.Single(c => c.BrowsePath[0].Name == "ConditionName").TypeDefinitionId
.ShouldBe(ObjectTypeIds.ConditionType);
call.Filter.WhereClause.Elements.ToArray()!.ShouldBeEmpty("no --of-type, no where-clause");
call.Filter.WhereClause.Elements.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.ToArray()!.Select(c => c.BrowsePath[0].Name).ShouldBe(["EventType", "Severity"]);
var element = filter.WhereClause.Elements.ToArray()!.ShouldHaveSingleItem();
filter.SelectClauses.Select(c => c.BrowsePath[0].Name).ShouldBe(["EventType", "Severity"]);
var element = filter.WhereClause.Elements.ShouldHaveSingleItem();
element.FilterOperator.ShouldBe(FilterOperator.OfType);
(element.FilterOperands[0].TryGetValue(out LiteralOperand? literal) ? literal.Value : Variant.Null)
((LiteralOperand)ExtensionObject.ToEncodeable(element.FilterOperands[0])).Value.Value
.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].WrappedValue.ShouldBe(Variant.From(2.5));
d.UpdateValues[1].Value.ShouldBe(2.5);
d.UpdateValues[0].SourceTimestamp.ShouldBe(new DateTime(2026, 1, 15, 8, 0, 0, DateTimeKind.Utc));
d.NodeId.IdentifierAsString.ShouldBe("HistNode");
d.NodeId.Identifier.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>();
ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(d.UpdateValues[0].WrappedValue).ShouldBeOfType(expected);
d.UpdateValues[0].Value.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;
new StatusCode(unnamed).SymbolicId.ShouldBeNullOrEmpty(); // precondition: genuinely unnamed
StatusCodes.GetBrowseName(unnamed).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.Value.NamespaceIndex.ShouldBe((ushort)2);
result.Value.IdentifierAsString.ShouldBe("MyNode");
result.NamespaceIndex.ShouldBe((ushort)2);
result.Identifier.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.Value.IdType.ShouldBe(IdType.Numeric);
result.IdType.ShouldBe(IdType.Numeric);
}
/// <summary>Verifies that Parse treats bare numeric input as namespace 0 numeric NodeId.</summary>
@@ -53,9 +53,8 @@ public class NodeIdParserTests
{
var result = NodeIdParser.Parse("85");
result.ShouldNotBeNull();
result.Value.NamespaceIndex.ShouldBe((ushort)0);
result.Value.TryGetValue(out uint identifier).ShouldBeTrue();
identifier.ShouldBe(85u);
result.NamespaceIndex.ShouldBe((ushort)0);
result.Identifier.ShouldBe((uint)85);
}
/// <summary>Verifies that Parse trims whitespace padding from input.</summary>
@@ -64,7 +63,7 @@ public class NodeIdParserTests
{
var result = NodeIdParser.Parse(" ns=2;s=MyNode ");
result.ShouldNotBeNull();
result.Value.IdentifierAsString.ShouldBe("MyNode");
result.Identifier.ShouldBe("MyNode");
}
/// <summary>Verifies that Parse throws FormatException for invalid input.</summary>
@@ -93,36 +92,7 @@ public class NodeIdParserTests
public void ParseRequired_ValidInput_ReturnsNodeId()
{
var result = NodeIdParser.ParseRequired("ns=2;s=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));
result.ShouldNotBeNull();
result.Identifier.ShouldBe("TestNode");
}
}
@@ -56,7 +56,7 @@ public class ReadCommandTests
await command.ExecuteAsync(console);
fakeService.ReadNodeIds.Count.ShouldBe(1);
fakeService.ReadNodeIds[0].IdentifierAsString.ShouldBe("MyVariable");
fakeService.ReadNodeIds[0].Identifier.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.IdentifierAsString.ToString() == "Tag1");
fakeService.SubscribeCalls.ShouldContain(c => c.NodeId.IdentifierAsString.ToString() == "Tag2");
fakeService.SubscribeCalls.ShouldContain(c => c.NodeId.Identifier.ToString() == "Tag1");
fakeService.SubscribeCalls.ShouldContain(c => c.NodeId.Identifier.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.IdentifierAsString.ShouldBe("TestVar");
fakeService.SubscribeCalls[0].NodeId.Identifier.ShouldBe("TestVar");
}
/// <summary>Verifies that ExecuteAsync unsubscribes when cancellation is requested.</summary>
@@ -1,97 +0,0 @@
using Opc.Ua;
using Opc.Ua.Configuration;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
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(false)]
[InlineData(true)]
public async Task New_certificates_use_the_default_lifetime_and_existing_private_keys_are_reused(bool seedLongerCertificate)
{
var ct = TestContext.Current.CancellationToken;
var root = Path.Combine(Path.GetTempPath(), $"otopcua-certificate-lifetime-{Guid.NewGuid():N}");
var settings = new ConnectionSettings
{
EndpointUrl = "opc.tcp://localhost:4840", CertificateStorePath = root,
SecurityMode = SecurityMode.None, AutoAcceptCertificates = false,
};
var factory = new DefaultApplicationConfigurationFactory();
ApplicationConfiguration? current = null;
try
{
if (seedLongerCertificate)
{
var seed = await factory.CreateAsync(settings, ct);
await using var application = new ApplicationInstance(DefaultTelemetry.Create(_ => { })) { ApplicationConfiguration = seed };
(await application.CheckApplicationInstanceCertificatesAsync(true, lifeTimeInMonths: 60, ct)).ShouldBeTrue();
}
settings.SecurityMode = SecurityMode.SignAndEncrypt;
current = await factory.CreateAsync(settings, ct);
var certPath = Directory.GetFiles(Path.Combine(root, "own", "certs"), "*.der").ShouldHaveSingleItem();
var privatePath = Directory.GetFiles(Path.Combine(root, "own", "private"), "*.pfx").ShouldHaveSingleItem();
using var certificate = X509CertificateLoader.LoadCertificateFromFile(certPath);
using var privateCertificate = X509CertificateLoader.LoadPkcs12FromFile(privatePath, null, OperatingSystem.IsMacOS() ? X509KeyStorageFlags.DefaultKeySet : X509KeyStorageFlags.EphemeralKeySet);
privateCertificate.HasPrivateKey.ShouldBeTrue();
certificate.Thumbprint.ShouldBe(privateCertificate.Thumbprint);
using var rsa = certificate.GetRSAPublicKey();
rsa.ShouldNotBeNull().KeySize.ShouldBeGreaterThanOrEqualTo(2048);
if (seedLongerCertificate)
certificate.NotAfter.ToUniversalTime().ShouldBeGreaterThan(DateTime.UtcNow.AddYears(4));
else
{
certificate.NotAfter.ToUniversalTime().ShouldBeGreaterThan(DateTime.UtcNow.AddMonths(CertificateFactory.DefaultLifeTime - 1));
certificate.NotAfter.ToUniversalTime().ShouldBeLessThan(DateTime.UtcNow.AddMonths(CertificateFactory.DefaultLifeTime).AddDays(2));
}
var originalKeyHash = SHA256.HashData(await File.ReadAllBytesAsync(privatePath, ct));
(current.CertificateManager as IDisposable)?.Dispose(); current = null;
current = await factory.CreateAsync(settings, ct);
using var reused = X509CertificateLoader.LoadCertificateFromFile(certPath);
reused.Thumbprint.ShouldBe(certificate.Thumbprint);
SHA256.HashData(await File.ReadAllBytesAsync(privatePath, ct)).ShouldBe(originalKeyHash,
"reinitializing with a shorter creation default must not rotate an existing certificate or private key");
Directory.GetFiles(Path.Combine(root, "own", "certs"), "*.der").Length.ShouldBe(1);
}
finally
{
(current?.CertificateManager as IDisposable)?.Dispose();
if (Directory.Exists(root)) Directory.Delete(root, true);
}
}
[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);
}
}
}
@@ -11,48 +11,6 @@ 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()
@@ -117,7 +117,7 @@ public class EndpointSelectorTests
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://external-host:4840",
MessageSecurityMode.Sign);
new Uri(best.EndpointUrl.ShouldNotBeNull()).Host.ShouldBe("external-host");
new Uri(best.EndpointUrl).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.ShouldNotBeNull());
var uri = new Uri(best.EndpointUrl);
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.ShouldNotBeNull()).Host.ShouldBe("internal-host");
new Uri(best.EndpointUrl.ShouldNotBeNull()).Port.ShouldBe(4843);
new Uri(best.EndpointUrl).Host.ShouldBe("internal-host");
new Uri(best.EndpointUrl).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.ShouldNotBeNull());
var uri = new Uri(best.EndpointUrl);
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.ShouldNotBeNull());
var uri = new Uri(best.EndpointUrl);
uri.Host.ShouldBe("[::1]");
uri.Port.ShouldBe(4841);
}
@@ -18,7 +18,7 @@ public sealed class AlarmEventFilterTests
{
var filter = OpcUaClientService.CreateAlarmEventFilter();
filter.SelectClauses.ToArray()!.SelectMany(c => c.BrowsePath.ToArray()!).ShouldAllBe(q => !(q.Name ?? "").Contains('/'));
filter.SelectClauses.SelectMany(c => c.BrowsePath).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.ToArray()!.Select(q => q.Name));
string Path(int i) => string.Join("/", clauses[i].BrowsePath.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 = Opc.Ua.NodeId.Parse("ns=2;s=X");
var node = new Opc.Ua.NodeId("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 = new LocalizedText("FakeServer"),
ApplicationName = "FakeServer",
ApplicationUri = "urn:localhost:FakeServer"
}
});
@@ -48,15 +48,11 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
public bool ThrowOnBrowse { get; set; }
/// <summary>Gets or sets the browse references returned by browse operations.</summary>
public ArrayOf<ReferenceDescription> BrowseResponse { get; set; } = [];
public ReferenceDescriptionCollection 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 ArrayOf<ReferenceDescription> BrowseNextResponse { get; set; } = [];
public ReferenceDescriptionCollection BrowseNextResponse { get; set; } = [];
/// <summary>Gets or sets the continuation point for browse-next operations.</summary>
public byte[]? BrowseNextContinuationPoint { get; set; }
@@ -133,7 +129,7 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
}
/// <inheritdoc />
public Task<(byte[]? ContinuationPoint, ArrayOf<ReferenceDescription> References)> BrowseAsync(
public Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync(
NodeId nodeId, uint nodeClassMask, CancellationToken ct)
{
BrowseCount++;
@@ -143,19 +139,11 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
}
/// <inheritdoc />
public async Task<(byte[]? ContinuationPoint, ArrayOf<ReferenceDescription> References)> BrowseNextAsync(
public Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
byte[] continuationPoint, CancellationToken ct)
{
BrowseNextCount++;
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;
return Task.FromResult((BrowseNextContinuationPoint, BrowseNextResponse));
}
/// <inheritdoc />
@@ -40,7 +40,7 @@ internal sealed class FakeSessionFactory : ISessionFactory
uint sessionTimeoutMs, UserIdentity identity, CancellationToken ct)
{
CreateCallCount++;
LastEndpointUrl = endpoint.EndpointUrl ?? string.Empty;
LastEndpointUrl = endpoint.EndpointUrl;
// 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 ?? string.Empty,
ServerName = endpoint.Server?.ApplicationName.Text ?? "FakeServer",
EndpointUrl = endpoint.EndpointUrl,
ServerName = endpoint.Server?.ApplicationName?.Text ?? "FakeServer",
SecurityMode = endpoint.SecurityMode.ToString(),
SecurityPolicyUri = endpoint.SecurityPolicyUri ?? string.Empty
};
// Ensure endpoint URL matches
session.EndpointUrl = endpoint.EndpointUrl ?? string.Empty;
session.EndpointUrl = endpoint.EndpointUrl;
_createdSessions.Add(session);
return session;
}
@@ -129,16 +129,6 @@ 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>
@@ -26,7 +26,8 @@ public class AggregateTypeMapperTests
public void ToNodeId_ReturnsNonNullForAllValues(AggregateType aggregate)
{
var nodeId = AggregateTypeMapper.ToNodeId(aggregate);
nodeId.IsNull.ShouldBeFalse();
nodeId.ShouldNotBeNull();
nodeId.IsNullNodeId.ShouldBeFalse();
}
/// <summary>Verifies that Average aggregate type maps to the correct OPC UA node ID.</summary>
@@ -108,9 +109,9 @@ public class AggregateTypeMapperTests
[MemberData(nameof(AllAggregates))]
public void ToNodeId_maps_to_the_same_named_AggregateFunction(AggregateType aggregate)
{
var property = typeof(ObjectIds).GetProperty("AggregateFunction_" + aggregate);
property.ShouldNotBeNull();
AggregateTypeMapper.ToNodeId(aggregate).ShouldBe((NodeId)property.GetValue(null)!);
var field = typeof(ObjectIds).GetField("AggregateFunction_" + aggregate);
field.ShouldNotBeNull();
AggregateTypeMapper.ToNodeId(aggregate).ShouldBe((NodeId)field.GetValue(null)!);
}
/// <summary>Every client aggregate.</summary>
@@ -29,18 +29,19 @@ 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 { DataValues = Enumerable.Range(0, count).Select(i => new DataValue(Variant.From(i))).ToArrayOf() };
var data = new HistoryData();
for (var i = 0; i < count; i++) data.DataValues.Add(new DataValue(new Variant(i)));
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
{
StatusCode = StatusCodes.Good,
ContinuationPoint = ByteString.From(next),
ContinuationPoint = next,
HistoryData = new ExtensionObject(data)
});
}
}
private static IEnumerable<DataValue> Values(HistoryReadResult r) =>
r.HistoryData.TryGetValue(out HistoryData? d) ? d.DataValues.ToArray() ?? [] : [];
r.HistoryData?.Body is HistoryData d ? d.DataValues : [];
/// <summary>
/// A server page limit below the caller's cap hands back a continuation point: the follow-up must READ the next
@@ -135,7 +136,8 @@ 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 { DataValues = [new DataValue(Variant.From(1))] };
var data = new HistoryData();
data.DataValues.Add(new DataValue(new Variant(1)));
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
{
StatusCode = StatusCodes.Good, ContinuationPoint = [5], HistoryData = new ExtensionObject(data)
@@ -163,7 +165,8 @@ public class HistoryReadPagingTests
releaseSawCancelled = ct.IsCancellationRequested;
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult { StatusCode = StatusCodes.Good });
}
var data = new HistoryData { DataValues = [new DataValue(Variant.From(1))] };
var data = new HistoryData();
data.DataValues.Add(new DataValue(new Variant(1)));
cts.Cancel(); // the caller gives up right as the capped page arrives
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
{
@@ -186,7 +189,8 @@ public class HistoryReadPagingTests
{
calls++;
if (release) throw new ServiceResultException(StatusCodes.BadSessionClosed);
var data = new HistoryData { DataValues = [new DataValue(Variant.From(1))] };
var data = new HistoryData();
data.DataValues.Add(new DataValue(new Variant(1)));
return Task.FromResult<HistoryReadResult?>(new HistoryReadResult
{
StatusCode = StatusCodes.Good, ContinuationPoint = [9], HistoryData = new ExtensionObject(data)
@@ -212,24 +216,4 @@ 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 });
}
}
@@ -7,29 +7,32 @@ 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]
[MemberData(nameof(Refusals))]
public void Bad_status_throws_ServiceResultException_with_the_code(StatusCode code)
[InlineData(StatusCodes.BadHistoryOperationUnsupported)]
[InlineData(StatusCodes.BadUserAccessDenied)]
[InlineData(StatusCodes.BadNodeIdUnknown)]
[InlineData(StatusCodes.BadTimeout)]
public void Bad_status_throws_ServiceResultException_with_the_code(uint code)
{
var ex = Should.Throw<ServiceResultException>(
() => HistoryReadStatus.ThrowIfRefused(code, new NodeId("n", 2)));
() => HistoryReadStatus.ThrowIfRefused(new StatusCode(code), new NodeId("n", 2)));
ex.StatusCode.ShouldBe(code.Code);
ex.StatusCode.ShouldBe(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]
[MemberData(nameof(NonRefusals))]
public void Non_refusal_status_does_not_throw(StatusCode code)
[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)
{
Should.NotThrow(() => HistoryReadStatus.ThrowIfRefused(code, new NodeId("n", 2)));
Should.NotThrow(() => HistoryReadStatus.ThrowIfRefused(new StatusCode(code), new NodeId("n", 2)));
}
}
@@ -1,126 +0,0 @@
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.WrappedValue.ShouldBe(Variant.From(42));
args.Value.Value.ShouldBe(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(NodeId.Parse("ns=2;s=MyNode"), TestContext.Current.CancellationToken);
var result = await _service.ReadValueAsync(new NodeId("ns=2;s=MyNode"), TestContext.Current.CancellationToken);
result.WrappedValue.ShouldBe(Variant.From(42));
result.Value.ShouldBe(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(NodeId.Parse("ns=2;s=MyNode")));
_service.ReadValueAsync(new NodeId("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(NodeId.Parse("ns=2;s=MyNode")));
_service.ReadValueAsync(new NodeId("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(NodeId.Parse("ns=2;s=MyNode"), 42, TestContext.Current.CancellationToken);
var result = await _service.WriteValueAsync(new NodeId("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(NodeId.Parse("ns=2;s=MyNode"), "42", TestContext.Current.CancellationToken);
await _service.WriteValueAsync(new NodeId("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(NodeId.Parse("ns=2;s=MyNode"), 42, TestContext.Current.CancellationToken);
await _service.WriteValueAsync(new NodeId("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(NodeId.Parse("ns=2;s=MyNode"), 42));
_service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), 42));
}
/// <summary>
@@ -303,13 +303,13 @@ public class OpcUaClientServiceTests : IDisposable
{
var session = new FakeSessionAdapter
{
ReadResponse = DataValue.FromStatusCode(StatusCodes.BadNodeIdUnknown) // Bad status, null Value
ReadResponse = new DataValue(StatusCodes.BadNodeIdUnknown) // Bad status, null Value
};
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
_service.WriteValueAsync(NodeId.Parse("ns=2;s=MyNode"), "42"));
_service.WriteValueAsync(new NodeId("ns=2;s=MyNode"), "42"));
ex.Message.ShouldContain("Cannot infer target type");
}
@@ -323,90 +323,17 @@ public class OpcUaClientServiceTests : IDisposable
{
var session = new FakeSessionAdapter
{
ReadResponse = DataValue.FromStatusCode(StatusCodes.BadNotReadable)
ReadResponse = new DataValue(StatusCodes.BadNotReadable)
};
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
_service.WriteValueAsync(NodeId.Parse("ns=2;s=MyNode"), "42"));
_service.WriteValueAsync(new NodeId("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>
@@ -421,7 +348,7 @@ public class OpcUaClientServiceTests : IDisposable
[
new ReferenceDescription
{
NodeId = ExpandedNodeId.Parse("ns=2;s=Child1"),
NodeId = new ExpandedNodeId("ns=2;s=Child1"),
DisplayName = new LocalizedText("Child1"),
NodeClass = NodeClass.Variable
}
@@ -470,19 +397,19 @@ public class OpcUaClientServiceTests : IDisposable
[
new ReferenceDescription
{
NodeId = ExpandedNodeId.Parse("ns=2;s=Folder1"),
NodeId = new ExpandedNodeId("ns=2;s=Folder1"),
DisplayName = new LocalizedText("Folder1"),
NodeClass = NodeClass.Object
},
new ReferenceDescription
{
NodeId = ExpandedNodeId.Parse("ns=2;s=Folder2"),
NodeId = new ExpandedNodeId("ns=2;s=Folder2"),
DisplayName = new LocalizedText("Folder2"),
NodeClass = NodeClass.Object
},
new ReferenceDescription
{
NodeId = ExpandedNodeId.Parse("ns=2;s=Tag"),
NodeId = new ExpandedNodeId("ns=2;s=Tag"),
DisplayName = new LocalizedText("Tag"),
NodeClass = NodeClass.Variable
}
@@ -510,7 +437,7 @@ public class OpcUaClientServiceTests : IDisposable
[
new ReferenceDescription
{
NodeId = ExpandedNodeId.Parse("ns=2;s=A"),
NodeId = new ExpandedNodeId("ns=2;s=A"),
DisplayName = new LocalizedText("A"),
NodeClass = NodeClass.Variable
}
@@ -520,7 +447,7 @@ public class OpcUaClientServiceTests : IDisposable
[
new ReferenceDescription
{
NodeId = ExpandedNodeId.Parse("ns=2;s=B"),
NodeId = new ExpandedNodeId("ns=2;s=B"),
DisplayName = new LocalizedText("B"),
NodeClass = NodeClass.Variable
}
@@ -557,7 +484,7 @@ public class OpcUaClientServiceTests : IDisposable
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
await _service.SubscribeAsync(NodeId.Parse("ns=2;s=MyNode"), 500, TestContext.Current.CancellationToken);
await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"), 500, TestContext.Current.CancellationToken);
session.CreatedSubscriptions.Count.ShouldBe(1);
session.CreatedSubscriptions[0].AddDataChangeCount.ShouldBe(1);
@@ -573,8 +500,8 @@ public class OpcUaClientServiceTests : IDisposable
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
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
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
session.CreatedSubscriptions[0].AddDataChangeCount.ShouldBe(1);
}
@@ -593,7 +520,7 @@ public class OpcUaClientServiceTests : IDisposable
DataChangedEventArgs? received = null;
_service.DataChanged += (_, e) => received = e;
await _service.SubscribeAsync(NodeId.Parse("ns=2;s=MyNode"), 500, TestContext.Current.CancellationToken);
await _service.SubscribeAsync(new NodeId("ns=2;s=MyNode"), 500, TestContext.Current.CancellationToken);
// Simulate data change
var handle = fakeSub.ActiveHandles.First();
@@ -601,7 +528,7 @@ public class OpcUaClientServiceTests : IDisposable
received.ShouldNotBeNull();
received!.NodeId.ShouldBe("ns=2;s=MyNode");
received.Value.WrappedValue.ShouldBe(Variant.From(99));
received.Value.Value.ShouldBe(99);
}
/// <summary>
@@ -615,8 +542,8 @@ public class OpcUaClientServiceTests : IDisposable
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings(), 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);
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);
fakeSub.RemoveCount.ShouldBe(1);
}
@@ -631,7 +558,7 @@ public class OpcUaClientServiceTests : IDisposable
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
await _service.UnsubscribeAsync(NodeId.Parse("ns=2;s=NotSubscribed"), TestContext.Current.CancellationToken);
await _service.UnsubscribeAsync(new NodeId("ns=2;s=NotSubscribed"), TestContext.Current.CancellationToken);
// Should not throw
}
@@ -642,7 +569,7 @@ public class OpcUaClientServiceTests : IDisposable
public async Task SubscribeAsync_WhenDisconnected_Throws()
{
await Should.ThrowAsync<InvalidOperationException>(() =>
_service.SubscribeAsync(NodeId.Parse("ns=2;s=MyNode")));
_service.SubscribeAsync(new NodeId("ns=2;s=MyNode")));
}
// --- Alarm subscription tests ---
@@ -815,7 +742,7 @@ public class OpcUaClientServiceTests : IDisposable
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
var results = await _service.HistoryReadRawAsync(NodeId.Parse("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, ct: TestContext.Current.CancellationToken);
var results = await _service.HistoryReadRawAsync(new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, ct: TestContext.Current.CancellationToken);
results.Count.ShouldBe(2);
session.HistoryReadRawCount.ShouldBe(1);
@@ -828,7 +755,7 @@ public class OpcUaClientServiceTests : IDisposable
public async Task HistoryReadRawAsync_WhenDisconnected_Throws()
{
await Should.ThrowAsync<InvalidOperationException>(() =>
_service.HistoryReadRawAsync(NodeId.Parse("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow));
_service.HistoryReadRawAsync(new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow));
}
/// <summary>
@@ -842,7 +769,7 @@ public class OpcUaClientServiceTests : IDisposable
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
await Should.ThrowAsync<ServiceResultException>(() =>
_service.HistoryReadRawAsync(NodeId.Parse("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow));
_service.HistoryReadRawAsync(new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow));
}
/// <summary>
@@ -859,7 +786,7 @@ public class OpcUaClientServiceTests : IDisposable
_sessionFactory.EnqueueSession(session);
await _service.ConnectAsync(ValidSettings(), 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);
var results = await _service.HistoryReadAggregateAsync(new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, AggregateType.Average, ct: TestContext.Current.CancellationToken);
results.Count.ShouldBe(1);
session.HistoryReadAggregateCount.ShouldBe(1);
@@ -873,7 +800,7 @@ public class OpcUaClientServiceTests : IDisposable
{
await Should.ThrowAsync<InvalidOperationException>(() =>
_service.HistoryReadAggregateAsync(
NodeId.Parse("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow,
new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow,
AggregateType.Average));
}
@@ -889,7 +816,7 @@ public class OpcUaClientServiceTests : IDisposable
await Should.ThrowAsync<ServiceResultException>(() =>
_service.HistoryReadAggregateAsync(
NodeId.Parse("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow,
new NodeId("ns=2;s=Temp"), DateTime.UtcNow.AddHours(-1), DateTime.UtcNow,
AggregateType.Average));
}
@@ -913,7 +840,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 DataValue.FromStatusCode(StatusCodes.BadNodeIdUnknown);
return new DataValue(StatusCodes.BadNodeIdUnknown);
}
};
_sessionFactory.EnqueueSession(session);
@@ -1006,9 +933,9 @@ public class OpcUaClientServiceTests : IDisposable
ReadResponseFunc = nodeId =>
{
if (nodeId == VariableIds.Server_ServerRedundancy_RedundancySupport)
return DataValue.FromStatusCode(StatusCodes.BadNotReadable); // bad status, null Value
return new DataValue(StatusCodes.BadNotReadable); // bad status, null Value
if (nodeId == VariableIds.Server_ServiceLevel)
return DataValue.FromStatusCode(StatusCodes.BadNotReadable);
return new DataValue(StatusCodes.BadNotReadable);
throw new ServiceResultException(StatusCodes.BadNodeIdUnknown);
}
};
@@ -1119,7 +1046,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.Code);
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled);
}
/// <summary>
@@ -1178,7 +1105,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.Code);
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled);
}
// --- ShelveAlarm tests ---
@@ -1259,7 +1186,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.Code);
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyShelved);
}
// --- Enable / Disable tests (H4 client path) ---
@@ -1322,7 +1249,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.Code);
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyEnabled);
}
/// <summary>
@@ -1343,7 +1270,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.Code);
result.Code.ShouldBe(StatusCodes.BadConditionAlreadyDisabled);
}
// --- Missing standard alarm fields (#598: the Galaxy-era supplemental reads are gone) ---
@@ -1557,7 +1484,7 @@ public class OpcUaClientServiceTests : IDisposable
var gate = new TaskCompletionSource();
session1.CreateSubscriptionGate = gate;
var subscribe = _service.SubscribeAsync(NodeId.Parse("ns=2;s=Racer"), 1000, TestContext.Current.CancellationToken);
var subscribe = _service.SubscribeAsync(new NodeId("ns=2;s=Racer"), 1000, TestContext.Current.CancellationToken);
await session1.CreateSubscriptionEntered.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
session1.SimulateKeepAlive(false);
@@ -1572,7 +1499,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(NodeId.Parse("ns=2;s=Racer"), TestContext.Current.CancellationToken);
await _service.UnsubscribeAsync(new NodeId("ns=2;s=Racer"), TestContext.Current.CancellationToken);
replayed.RemoveCount.ShouldBe(1);
replayed.ActiveHandles.ShouldBeEmpty();
}
@@ -1621,7 +1548,7 @@ public class OpcUaClientServiceTests : IDisposable
var tasks = new List<Task>();
for (var i = 0; i < 50; i++)
{
var nodeId = NodeId.Parse($"ns=2;s=Node{i}");
var nodeId = new NodeId($"ns=2;s=Node{i}");
tasks.Add(Task.Run(async () =>
{
await _service.SubscribeAsync(nodeId);
@@ -1633,55 +1560,6 @@ 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>
@@ -1720,7 +1598,7 @@ public class OpcUaClientServiceTests : IDisposable
await Should.ThrowAsync<ObjectDisposedException>(() =>
_service.ConnectAsync(ValidSettings()));
await Should.ThrowAsync<ObjectDisposedException>(() =>
_service.ReadValueAsync(NodeId.Parse("ns=2;s=X")));
_service.ReadValueAsync(new NodeId("ns=2;s=X")));
}
// --- Factory tests ---
@@ -1765,7 +1643,7 @@ public class OpcUaClientServiceTests : IDisposable
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
// Subscribe data (uses dataFakeSub from session.NextSubscription).
await _service.SubscribeAsync(NodeId.Parse("ns=2;s=Node1"), ct: TestContext.Current.CancellationToken);
await _service.SubscribeAsync(new NodeId("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.Value.ToString(), out var perParent))
if (parentNodeId != null && BrowseResultsByParent.TryGetValue(parentNodeId.ToString(), out var perParent))
return Task.FromResult(perParent);
return Task.FromResult(BrowseResults);
@@ -23,26 +23,6 @@ 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()

Some files were not shown because too many files have changed in this diff Show More