Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47b66caa8d | |||
| c6ce30121c | |||
| 62c3ba6c37 | |||
| b583840fbc |
@@ -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
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+14
-23
@@ -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);
|
||||
}
|
||||
}
|
||||
-1579
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-247
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-33
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-278
@@ -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);
|
||||
}
|
||||
-505
@@ -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.
|
||||
-355
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-250
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-342
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-42
@@ -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 <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>
|
||||
|
||||
-97
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
-42
@@ -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()
|
||||
|
||||
+6
-6
@@ -117,7 +117,7 @@ public class EndpointSelectorTests
|
||||
var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://external-host:4840",
|
||||
MessageSecurityMode.Sign);
|
||||
|
||||
new Uri(best.EndpointUrl.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>
|
||||
|
||||
+5
-4
@@ -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>
|
||||
|
||||
+10
-26
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
+14
-11
@@ -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
Reference in New Issue
Block a user