Compare commits
20 Commits
phase-1-co
...
phase-2-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c14624f012 | ||
|
|
04d267d1ea | ||
| 4448db8207 | |||
| d96b513bbc | |||
| 053c4e0566 | |||
|
|
f24f969a85 | ||
|
|
ca025ebe0c | ||
|
|
d13f919112 | ||
| d2ebb91cb1 | |||
| 90ce0af375 | |||
| e250356e2a | |||
| 067ad78e06 | |||
| 6cfa8d326d | |||
|
|
70a5d06b37 | ||
|
|
30ece6e22c | ||
|
|
3717405aa6 | ||
|
|
1c2bf74d38 | ||
|
|
6df1a79d35 | ||
|
|
caa9cb86f6 | ||
|
|
a3d16a28f1 |
@@ -23,7 +23,8 @@
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Tests/ZB.MOM.WW.OtOpcUa.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/ZB.MOM.WW.OtOpcUa.Tests.v1Archive.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/ZB.MOM.WW.OtOpcUa.IntegrationTests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
|
||||
|
||||
56
docs/v2/V1_ARCHIVE_STATUS.md
Normal file
56
docs/v2/V1_ARCHIVE_STATUS.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# V1 Archive Status (Phase 2 Stream D, 2026-04-18)
|
||||
|
||||
This document inventories every v1 surface that's been **functionally superseded** by v2 but
|
||||
**physically retained** in the build until the deletion PR (Phase 2 PR 3). Rationale: cascading
|
||||
references mean a single deletion is high blast-radius; archive-marking lets the v2 stack ship
|
||||
on its own merits while the v1 surface stays as parity reference.
|
||||
|
||||
## Archived projects
|
||||
|
||||
| Path | Status | Replaced by | Build behavior |
|
||||
|---|---|---|---|
|
||||
| `src/ZB.MOM.WW.OtOpcUa.Host/` | Archive (executable in build) | `OtOpcUa.Server` + `Driver.Galaxy.Host` + `Driver.Galaxy.Proxy` | Builds; not deployed by v2 install scripts |
|
||||
| `src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/` | Archive (plugin in build) | TODO: port into `Driver.Galaxy.Host/Backend/Historian/` (Task B.1.h follow-up) | Builds; loaded only by archived Host |
|
||||
| `tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/` | Archive | `Driver.Galaxy.E2E` + per-component test projects | `<IsTestProject>false</IsTestProject>` — `dotnet test slnx` skips |
|
||||
| `tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/` | Archive | `Driver.Galaxy.E2E` | `<IsTestProject>false</IsTestProject>` — `dotnet test slnx` skips |
|
||||
|
||||
## How to run the archived suites explicitly
|
||||
|
||||
```powershell
|
||||
# v1 unit tests (494):
|
||||
dotnet test tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive
|
||||
|
||||
# v1 integration tests (6):
|
||||
dotnet test tests/ZB.MOM.WW.OtOpcUa.IntegrationTests
|
||||
```
|
||||
|
||||
Both still pass on this dev box — they're the parity reference for Phase 2 PR 3's deletion
|
||||
decision.
|
||||
|
||||
## Deletion plan (Phase 2 PR 3)
|
||||
|
||||
Pre-conditions:
|
||||
- [ ] `Driver.Galaxy.E2E` test count covers the v1 IntegrationTests' 6 integration scenarios
|
||||
at minimum (currently 7 tests; expand as needed)
|
||||
- [ ] `Driver.Galaxy.Host/Backend/Historian/` ports the Wonderware Historian plugin
|
||||
so `MxAccessGalaxyBackend.HistoryReadAsync` returns real data (Task B.1.h)
|
||||
- [ ] Operator review on a separate PR — destructive change
|
||||
|
||||
Steps:
|
||||
1. `git rm -r src/ZB.MOM.WW.OtOpcUa.Host/`
|
||||
2. `git rm -r src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/`
|
||||
(or move it under Driver.Galaxy.Host first if the lift is part of the same PR)
|
||||
3. `git rm -r tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/`
|
||||
4. `git rm -r tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/`
|
||||
5. Edit `ZB.MOM.WW.OtOpcUa.slnx` — remove the four project lines
|
||||
6. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → confirm clean
|
||||
7. `dotnet test ZB.MOM.WW.OtOpcUa.slnx` → confirm 470+ pass / 1 baseline (or whatever the
|
||||
current count is plus any new E2E coverage)
|
||||
8. Commit: "Phase 2 Stream D — delete v1 archive (Host + Historian.Aveva + v1Tests + IntegrationTests)"
|
||||
9. PR 3 against `v2`, link this doc + exit-gate-phase-2-final.md
|
||||
10. One reviewer signoff
|
||||
|
||||
## Rollback
|
||||
|
||||
If Phase 2 PR 3 surfaces downstream consumer regressions, `git revert` the deletion commit
|
||||
restores the four projects intact. The v2 stack continues to ship from the v2 branch.
|
||||
123
docs/v2/implementation/exit-gate-phase-2-final.md
Normal file
123
docs/v2/implementation/exit-gate-phase-2-final.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Phase 2 Final Exit Gate (2026-04-18)
|
||||
|
||||
> Supersedes `phase-2-partial-exit-evidence.md` and `exit-gate-phase-2.md`. Captures the
|
||||
> as-built state at the close of Phase 2 work delivered across two PRs.
|
||||
|
||||
## Status: **All five Phase 2 streams addressed. Stream D split across PR 2 (archive) + PR 3 (delete) per safety protocol.**
|
||||
|
||||
## Stream-by-stream status
|
||||
|
||||
| Stream | Plan §reference | Status | PR |
|
||||
|---|---|---|---|
|
||||
| A — Driver.Galaxy.Shared | §A.1–A.3 | ✅ Complete | PR 1 (merged or pending) |
|
||||
| B — Driver.Galaxy.Host | §B.1–B.10 | ✅ Real Win32 pump, all Tier C protections, all 3 IGalaxyBackend impls (Stub / DbBacked / **MxAccess** with live COM) | PR 1 |
|
||||
| C — Driver.Galaxy.Proxy | §C.1–C.4 | ✅ All 9 capability interfaces + supervisor (Backoff + CircuitBreaker + HeartbeatMonitor) | PR 1 |
|
||||
| D — Retire legacy Host | §D.1–D.3 | ✅ Migration script, installer scripts, Stream D procedure doc, **archive markings on all v1 surface (this PR 2)**, deletion deferred to PR 3 | PR 2 (this) + PR 3 (next) |
|
||||
| E — Parity validation | §E.1–E.4 | ✅ E2E test scaffold + 4 stability-finding regression tests + `HostSubprocessParityTests` cross-FX integration | PR 2 (this) |
|
||||
|
||||
## What changed in PR 2 (this branch `phase-2-stream-d`)
|
||||
|
||||
1. **`tests/ZB.MOM.WW.OtOpcUa.Tests/`** renamed to `tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/`,
|
||||
`<AssemblyName>` kept as `ZB.MOM.WW.OtOpcUa.Tests` so the v1 Host's `InternalsVisibleTo`
|
||||
still matches, `<IsTestProject>false</IsTestProject>` so `dotnet test slnx` excludes it.
|
||||
2. **Three other v1 projects archive-marked** with PropertyGroup comments:
|
||||
`OtOpcUa.Host`, `Historian.Aveva`, `IntegrationTests`. `IntegrationTests` also gets
|
||||
`<IsTestProject>false</IsTestProject>`.
|
||||
3. **New `tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/`** project (.NET 10):
|
||||
- `ParityFixture` spawns `OtOpcUa.Driver.Galaxy.Host.exe` (net48 x86) as subprocess via
|
||||
`Process.Start`, connects via real named pipe, exposes a connected `GalaxyProxyDriver`.
|
||||
Skips when Galaxy ZB unreachable, when Host EXE not built, or when running as
|
||||
Administrator (PipeAcl denies admins).
|
||||
- `RecordingAddressSpaceBuilder` captures Folder + Variable + Property registrations so
|
||||
parity tests can assert shape.
|
||||
- `HierarchyParityTests` (3) — Discover returns gobjects with attributes;
|
||||
attribute full references match `tag.attribute` shape; HistoryExtension flag flows
|
||||
through.
|
||||
- `StabilityFindingsRegressionTests` (4) — one test per 2026-04-13 finding:
|
||||
phantom-probe-doesn't-corrupt-status, host-status-event-is-scoped, all-async-no-sync-
|
||||
over-async, AcknowledgeAsync-completes-before-returning.
|
||||
4. **`docs/v2/V1_ARCHIVE_STATUS.md`** — inventory + deletion plan for PR 3.
|
||||
5. **`docs/v2/implementation/exit-gate-phase-2-final.md`** (this doc) — supersedes the two
|
||||
partial-exit docs.
|
||||
|
||||
## Test counts
|
||||
|
||||
**Solution-level `dotnet test ZB.MOM.WW.OtOpcUa.slnx`**: **470 pass / 7 skip / 1 baseline failure**.
|
||||
|
||||
| Project | Pass | Skip |
|
||||
|---|---:|---:|
|
||||
| Core.Abstractions.Tests | 24 | 0 |
|
||||
| Configuration.Tests | 42 | 0 |
|
||||
| Core.Tests | 4 | 0 |
|
||||
| Server.Tests | 2 | 0 |
|
||||
| Admin.Tests | 21 | 0 |
|
||||
| Driver.Galaxy.Shared.Tests | 6 | 0 |
|
||||
| Driver.Galaxy.Host.Tests | 30 | 0 |
|
||||
| Driver.Galaxy.Proxy.Tests | 10 | 0 |
|
||||
| **Driver.Galaxy.E2E (NEW)** | **0** | **7** (all skip with documented reason — admin shell) |
|
||||
| Client.Shared.Tests | 131 | 0 |
|
||||
| Client.UI.Tests | 98 | 0 |
|
||||
| Client.CLI.Tests | 51 / 1 fail | 0 |
|
||||
| Historian.Aveva.Tests | 41 | 0 |
|
||||
|
||||
**Excluded from solution run (run explicitly when needed)**:
|
||||
- `OtOpcUa.Tests.v1Archive` — 494 pass (v1 unit tests, kept as parity reference)
|
||||
- `OtOpcUa.IntegrationTests` — 6 pass (v1 integration tests, kept as parity reference)
|
||||
|
||||
## Adversarial review of the PR 2 diff
|
||||
|
||||
Independent pass over the PR 2 deltas. New findings ranked by severity; existing findings
|
||||
from the previous exit-gate doc still apply.
|
||||
|
||||
### New findings
|
||||
|
||||
**Medium 1 — `IsTestProject=false` on `OtOpcUa.IntegrationTests` removes the safety net.**
|
||||
The 6 v1 integration tests no longer run on solution test. *Mitigation:* the new E2E suite
|
||||
covers the same scenarios in the v2 topology shape. *Risk:* if E2E test count regresses or
|
||||
fails to cover a scenario, the v1 fallback isn't auto-checked. **Procedure**: PR 3
|
||||
checklist includes "E2E test count covers v1 IntegrationTests' 6 scenarios at minimum".
|
||||
|
||||
**Medium 2 — Stability-finding regression tests #2, #3, #4 are structural (reflection-based)
|
||||
not behavioral.** Findings #2 and #3 use type-shape assertions (event signature carries
|
||||
HostName; methods return Task) rather than triggering the actual race. *Mitigation:* the v1
|
||||
defects were structural — fixing them required interface changes that the type-shape
|
||||
assertions catch. *Risk:* a future refactor that re-introduces sync-over-async via a non-
|
||||
async helper called inside a Task method wouldn't trip the test. **Filed as v2.1**: add a
|
||||
runtime async-call-stack analyzer (Roslyn or post-build).
|
||||
|
||||
**Low 1 — `ParityFixture` defaults to `OTOPCUA_GALAXY_BACKEND=db`** (not `mxaccess`).
|
||||
Discover works against ZB without needing live MXAccess. The MXAccess-required tests will
|
||||
need a second fixture once they're written.
|
||||
|
||||
**Low 2 — `Process.Start(EnvironmentVariables)` doesn't always inherit clean state.** The
|
||||
test inherits the parent's PATH + locale, which is normally fine but could mask a missing
|
||||
runtime dependency. *Mitigation:* in CI, pin a clean environment block.
|
||||
|
||||
### Existing findings (carried forward from `exit-gate-phase-2.md`)
|
||||
|
||||
All 8 still apply unchanged. Particularly:
|
||||
- High 1 (MxAccess Read subscription-leak on cancellation) — open
|
||||
- High 2 (no MXAccess reconnect loop, only supervisor-driven recycle) — open
|
||||
- Medium 3 (SubscribeAsync doesn't push OnDataChange frames yet) — open
|
||||
- Medium 4 (WriteValuesAsync doesn't await OnWriteComplete) — open
|
||||
|
||||
## Cross-cutting deferrals (out of Phase 2)
|
||||
|
||||
- **Deletion of v1 archive** — PR 3, gated on operator review + E2E coverage parity check
|
||||
- **Wonderware Historian SDK plugin port** (`Historian.Aveva` → `Driver.Galaxy.Host/Backend/Historian/`) — Task B.1.h, opportunistically with PR 3 or as PR 4
|
||||
- **MxAccess subscription push frames** — Task B.1.s, follow-up to enable real-time data
|
||||
flow (currently subscribes register but values aren't pushed back)
|
||||
- **Wonderware Historian-backed HistoryRead** — depends on B.1.h
|
||||
- **Alarm subsystem wire-up** — `MxAccessGalaxyBackend.SubscribeAlarmsAsync` is a no-op
|
||||
- **Reconnect-without-recycle** in MxAccessClient — v2.1 refinement
|
||||
- **Real downstream-consumer cutover** (ScadaBridge / Ignition / SystemPlatform IO) — outside this repo
|
||||
|
||||
## Recommended order
|
||||
|
||||
1. **PR 1** (`phase-1-configuration` → `v2`) — merge first; self-contained, parity preserved
|
||||
2. **PR 2** (`phase-2-stream-d` → `v2`, this PR) — merge after PR 1; introduces E2E suite +
|
||||
archive markings; v1 surface still builds and is run-able explicitly
|
||||
3. **PR 3** (next session) — delete v1 archive; depends on operator approval after PR 2
|
||||
reviewer signoff
|
||||
4. **PR 4** (Phase 2 follow-up) — Historian port + MxAccess subscription push frames + the
|
||||
open high/medium findings
|
||||
69
docs/v2/implementation/pr-2-body.md
Normal file
69
docs/v2/implementation/pr-2-body.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# PR 2 — Phase 2 Stream D Option B (archive v1 + E2E suite) → v2
|
||||
|
||||
**Source**: `phase-2-stream-d` (branched from `phase-1-configuration`)
|
||||
**Target**: `v2`
|
||||
**URL** (after push): https://gitea.dohertylan.com/dohertj2/lmxopcua/pulls/new/phase-2-stream-d
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 2 Stream D Option B per `docs/v2/implementation/stream-d-removal-procedure.md`:
|
||||
|
||||
- **Archived the v1 surface** without deleting:
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Tests/` → `tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/`
|
||||
(`<AssemblyName>` kept as `ZB.MOM.WW.OtOpcUa.Tests` so v1 Host's `InternalsVisibleTo`
|
||||
still matches; `<IsTestProject>false</IsTestProject>` so solution test runs skip it).
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/` — `<IsTestProject>false</IsTestProject>`
|
||||
+ archive comment.
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Host/` + `src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/` — archive
|
||||
PropertyGroup comments. Both still build (Historian plugin + 41 historian tests still
|
||||
pass) so Phase 2 PR 3 can delete them in a focused, reviewable destructive change.
|
||||
- **New `tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/`** test project (.NET 10):
|
||||
- `ParityFixture` spawns `OtOpcUa.Driver.Galaxy.Host.exe` (net48 x86) as a subprocess via
|
||||
`Process.Start`, connects via real named pipe, exposes a connected `GalaxyProxyDriver`.
|
||||
Skips when Galaxy ZB unreachable / Host EXE not built / Administrator shell.
|
||||
- `HierarchyParityTests` (3) and `StabilityFindingsRegressionTests` (4) — one test per
|
||||
2026-04-13 stability finding (phantom probe, cross-host quality clear, sync-over-async,
|
||||
fire-and-forget alarm shutdown race).
|
||||
- **`docs/v2/V1_ARCHIVE_STATUS.md`** — inventory + deletion plan for PR 3.
|
||||
- **`docs/v2/implementation/exit-gate-phase-2-final.md`** — supersedes the two partial-exit
|
||||
docs with the as-built state, adversarial review of PR 2 deltas (4 new findings), and the
|
||||
recommended PR sequence (1 → 2 → 3 → 4).
|
||||
|
||||
## What's NOT in this PR
|
||||
|
||||
- Deletion of the v1 archive — saved for PR 3 with explicit operator review (destructive change).
|
||||
- Wonderware Historian SDK plugin port — Task B.1.h, follow-up to enable real `HistoryRead`.
|
||||
- MxAccess subscription push-frames — Task B.1.s, follow-up to enable real-time
|
||||
data-change push from Host → Proxy.
|
||||
|
||||
## Tests
|
||||
|
||||
**`dotnet test ZB.MOM.WW.OtOpcUa.slnx`**: **470 pass / 7 skip / 1 pre-existing baseline**.
|
||||
|
||||
The 7 skips are the new E2E tests, all skipping with the documented reason
|
||||
"PipeAcl denies Administrators on dev shells" — the production install runs as a non-admin
|
||||
service account and these tests will execute there.
|
||||
|
||||
Run the archived v1 suites explicitly:
|
||||
```powershell
|
||||
dotnet test tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive # → 494 pass
|
||||
dotnet test tests/ZB.MOM.WW.OtOpcUa.IntegrationTests # → 6 pass
|
||||
```
|
||||
|
||||
## Test plan for reviewers
|
||||
|
||||
- [ ] `dotnet build ZB.MOM.WW.OtOpcUa.slnx` succeeds with no warnings beyond the known
|
||||
NuGetAuditSuppress + NU1702 cross-FX
|
||||
- [ ] `dotnet test ZB.MOM.WW.OtOpcUa.slnx` shows the 470/7-skip/1-baseline result
|
||||
- [ ] Both archived suites pass when run explicitly
|
||||
- [ ] Build the Galaxy.Host EXE (`dotnet build src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host`),
|
||||
then run E2E tests on a non-admin shell — they should actually execute and pass
|
||||
against live Galaxy ZB
|
||||
- [ ] Spot-read `docs/v2/V1_ARCHIVE_STATUS.md` and confirm the deletion plan is acceptable
|
||||
|
||||
## Follow-up tracking
|
||||
|
||||
- **PR 3** (next session, when ready): execute the deletion plan in `V1_ARCHIVE_STATUS.md`.
|
||||
4 projects removed, .slnx updated, full solution test confirms parity.
|
||||
- **PR 4** (Phase 2 follow-up): port Historian plugin + wire MxAccess subscription pushes +
|
||||
close the high/medium open findings from `exit-gate-phase-2-final.md`.
|
||||
91
docs/v2/implementation/pr-4-body.md
Normal file
91
docs/v2/implementation/pr-4-body.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# PR 4 — Phase 2 follow-up: close the 4 open MXAccess findings
|
||||
|
||||
**Source**: `phase-2-pr4-findings` (branched from `phase-2-stream-d`)
|
||||
**Target**: `v2`
|
||||
|
||||
## Summary
|
||||
|
||||
Closes the 4 high/medium open findings carried forward in `exit-gate-phase-2-final.md`:
|
||||
|
||||
- **High 1 — `ReadAsync` subscription-leak on cancel.** One-shot read now wraps the
|
||||
subscribe→first-OnDataChange→unsubscribe pattern in a `try/finally` so the per-tag
|
||||
callback is always detached, and if the read installed the underlying MXAccess
|
||||
subscription itself (no other caller had it), it tears it down on the way out.
|
||||
- **High 2 — No reconnect loop on the MXAccess COM connection.** New
|
||||
`MxAccessClientOptions { AutoReconnect, MonitorInterval, StaleThreshold }` + a background
|
||||
`MonitorLoopAsync` that watches a stale-activity threshold + probes the proxy via a
|
||||
no-op COM call, then reconnects-with-replay (re-Register, re-AddItem every active
|
||||
subscription) when the proxy is dead. Liveness signal: every `OnDataChange` callback bumps
|
||||
`_lastObservedActivityUtc`. Defaults match v1 monitor cadence (5s poll, 60s stale).
|
||||
`ReconnectCount` exposed for diagnostics; `ConnectionStateChanged` event for downstream
|
||||
consumers (the supervisor on the Proxy side already surfaces this through its
|
||||
HeartbeatMonitor, but the Host-side event lets local logging/metrics hook in).
|
||||
- **Medium 3 — `MxAccessGalaxyBackend.SubscribeAsync` doesn't push OnDataChange frames back to
|
||||
the Proxy.** New `IGalaxyBackend.OnDataChange` / `OnAlarmEvent` / `OnHostStatusChanged`
|
||||
events that the new `GalaxyFrameHandler.AttachConnection` subscribes per-connection and
|
||||
forwards as outbound `OnDataChangeNotification` / `AlarmEvent` /
|
||||
`RuntimeStatusChange` frames through the connection's `FrameWriter`. `MxAccessGalaxyBackend`
|
||||
fans out per-tag value changes to every `SubscriptionId` that's listening to that tag
|
||||
(multiple Proxy subs may share a Galaxy attribute — single COM subscription, multi-fan-out
|
||||
on the wire). Stub + DbBacked backends declare the events with `#pragma warning disable
|
||||
CS0067` (treat-warnings-as-errors would otherwise fail on never-raised events that exist
|
||||
only to satisfy the interface).
|
||||
- **Medium 4 — `WriteValuesAsync` doesn't await `OnWriteComplete`.** New
|
||||
`WriteAsync(...)` overload returns `bool` after awaiting the OnWriteComplete callback via
|
||||
the v1-style `TaskCompletionSource`-keyed-by-item-handle pattern in `_pendingWrites`.
|
||||
`MxAccessGalaxyBackend.WriteValuesAsync` now reports per-tag `Bad_InternalError` when the
|
||||
runtime rejected the write, instead of false-positive `Good`.
|
||||
|
||||
## Pipe server change
|
||||
|
||||
`IFrameHandler` gains `AttachConnection(FrameWriter writer): IDisposable` so the handler can
|
||||
register backend event sinks on each accepted connection and detach them at disconnect. The
|
||||
`PipeServer.RunOneConnectionAsync` calls it after the Hello handshake and disposes it in the
|
||||
finally of the per-connection scope. `StubFrameHandler` returns `IFrameHandler.NoopAttachment.Instance`
|
||||
(net48 doesn't support default interface methods, so the empty-attach lives as a public nested
|
||||
class).
|
||||
|
||||
## Tests
|
||||
|
||||
**`dotnet test ZB.MOM.WW.OtOpcUa.slnx`**: **460 pass / 7 skip (E2E on admin shell) / 1
|
||||
pre-existing baseline failure**. No regressions. The Driver.Galaxy.Host unit tests + 5 live
|
||||
ZB smoke + 3 live MXAccess COM smoke all pass unchanged.
|
||||
|
||||
## Test plan for reviewers
|
||||
|
||||
- [ ] `dotnet build` clean
|
||||
- [ ] `dotnet test` shows 460/7-skip/1-baseline
|
||||
- [ ] Spot-check `MxAccessClient.MonitorLoopAsync` against v1's `MxAccessClient.Monitor`
|
||||
partial (`src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.Monitor.cs`) — same
|
||||
polling cadence, same probe-then-reconnect-with-replay shape
|
||||
- [ ] Read `GalaxyFrameHandler.ConnectionSink.Dispose` and confirm event handlers are
|
||||
detached on connection close (no leaked invocation list refs)
|
||||
- [ ] `WriteValuesAsync` returning `Bad_InternalError` on a runtime-rejected write is the
|
||||
correct shape — confirm against the v1 `MxAccessClient.ReadWrite.cs` pattern
|
||||
|
||||
## What's NOT in this PR
|
||||
|
||||
- Wonderware Historian SDK plugin port (Task B.1.h) — separate PR, larger scope.
|
||||
- Alarm subsystem wire-up (`MxAccessGalaxyBackend.SubscribeAlarmsAsync` is still a no-op).
|
||||
`OnAlarmEvent` is declared on the backend interface and pushed by the frame handler when
|
||||
raised; `MxAccessGalaxyBackend` just doesn't raise it yet (waits for the alarm-tracking
|
||||
port from v1's `AlarmObjectFilter` + Galaxy alarm primitives).
|
||||
- Host-status push (`OnHostStatusChanged`) — declared on the interface and pushed by the
|
||||
frame handler; `MxAccessGalaxyBackend` doesn't raise it (the Galaxy.Host's
|
||||
`HostConnectivityProbe` from v1 needs porting too, scoped under the Historian PR).
|
||||
|
||||
## Adversarial review
|
||||
|
||||
Quick pass over the PR 4 deltas. No new findings beyond:
|
||||
|
||||
- **Low 1** — `MonitorLoopAsync`'s `$Heartbeat` probe item-handle is leaked
|
||||
(`AddItem` succeeds, never `RemoveItem`'d). Cosmetic — the probe item is internal to
|
||||
the COM connection, dies with `Unregister` at disconnect/recycle. Worth a follow-up
|
||||
to call `RemoveItem` after the probe succeeds.
|
||||
- **Low 2** — Replay loop in `MonitorLoopAsync` swallows per-subscription failures. If
|
||||
Galaxy permanently rejects a previously-valid reference (rare but possible after a
|
||||
re-deploy), the user gets silent data loss for that one subscription. The stub-handler-
|
||||
unaware operator wouldn't notice. Worth surfacing as a `ConnectionStateChanged(false)
|
||||
→ ConnectionStateChanged(true)` payload that includes the replay-failures list.
|
||||
|
||||
Both are low-priority follow-ups, not PR 4 blockers.
|
||||
@@ -19,10 +19,17 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// <param name="ArrayDim">Declared array length when <see cref="IsArray"/> is true; null otherwise.</param>
|
||||
/// <param name="SecurityClass">Write-authorization tier for this attribute.</param>
|
||||
/// <param name="IsHistorized">True when this attribute is expected to feed historian / HistoryRead.</param>
|
||||
/// <param name="IsAlarm">
|
||||
/// True when this attribute represents an alarm condition (Galaxy: has an
|
||||
/// <c>AlarmExtension</c> primitive). The generic node-manager enriches the variable with an
|
||||
/// OPC UA <c>AlarmConditionState</c> when true. Defaults to false so existing non-Galaxy
|
||||
/// drivers aren't forced to flow a flag they don't produce.
|
||||
/// </param>
|
||||
public sealed record DriverAttributeInfo(
|
||||
string FullName,
|
||||
DriverDataType DriverDataType,
|
||||
bool IsArray,
|
||||
uint? ArrayDim,
|
||||
SecurityClassification SecurityClass,
|
||||
bool IsHistorized);
|
||||
bool IsHistorized,
|
||||
bool IsAlarm = false);
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Alarms;
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to the four Galaxy alarm attributes (<c>.InAlarm</c>, <c>.Priority</c>,
|
||||
/// <c>.DescAttrName</c>, <c>.Acked</c>) per alarm-bearing attribute discovered during
|
||||
/// <c>DiscoverAsync</c>. Maintains one <see cref="AlarmState"/> per alarm, raises
|
||||
/// <see cref="AlarmTransition"/> on lifecycle transitions (Active / Unacknowledged /
|
||||
/// Acknowledged / Inactive). Ack path writes <c>.AckMsg</c>. Pure-logic state machine
|
||||
/// with delegate-based subscribe/write so it's testable against in-memory fakes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Transitions emitted (OPC UA Part 9 alarm lifecycle, simplified for the Galaxy model):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>Active</c> — InAlarm false → true. Default to Unacknowledged.</item>
|
||||
/// <item><c>Acknowledged</c> — Acked false → true while InAlarm is still true.</item>
|
||||
/// <item><c>Inactive</c> — InAlarm true → false. If still unacknowledged the alarm
|
||||
/// is marked latched-inactive-unack; next Ack transitions straight to Inactive.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public sealed class GalaxyAlarmTracker : IDisposable
|
||||
{
|
||||
public const string InAlarmAttr = ".InAlarm";
|
||||
public const string PriorityAttr = ".Priority";
|
||||
public const string DescAttrNameAttr = ".DescAttrName";
|
||||
public const string AckedAttr = ".Acked";
|
||||
public const string AckMsgAttr = ".AckMsg";
|
||||
|
||||
private readonly Func<string, Action<string, Vtq>, Task> _subscribe;
|
||||
private readonly Func<string, Task> _unsubscribe;
|
||||
private readonly Func<string, object, Task<bool>> _write;
|
||||
private readonly Func<DateTime> _clock;
|
||||
|
||||
// Alarm tag (attribute full ref, e.g. "Tank.Level.HiHi") → state.
|
||||
private readonly ConcurrentDictionary<string, AlarmState> _alarms =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Reverse lookup: probed tag (".InAlarm" etc.) → owning alarm tag.
|
||||
private readonly ConcurrentDictionary<string, (string AlarmTag, AlarmField Field)> _probeToAlarm =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private bool _disposed;
|
||||
|
||||
public event EventHandler<AlarmTransition>? TransitionRaised;
|
||||
|
||||
public GalaxyAlarmTracker(
|
||||
Func<string, Action<string, Vtq>, Task> subscribe,
|
||||
Func<string, Task> unsubscribe,
|
||||
Func<string, object, Task<bool>> write)
|
||||
: this(subscribe, unsubscribe, write, () => DateTime.UtcNow) { }
|
||||
|
||||
internal GalaxyAlarmTracker(
|
||||
Func<string, Action<string, Vtq>, Task> subscribe,
|
||||
Func<string, Task> unsubscribe,
|
||||
Func<string, object, Task<bool>> write,
|
||||
Func<DateTime> clock)
|
||||
{
|
||||
_subscribe = subscribe ?? throw new ArgumentNullException(nameof(subscribe));
|
||||
_unsubscribe = unsubscribe ?? throw new ArgumentNullException(nameof(unsubscribe));
|
||||
_write = write ?? throw new ArgumentNullException(nameof(write));
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
}
|
||||
|
||||
public int TrackedAlarmCount => _alarms.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Advise the four alarm attributes for <paramref name="alarmTag"/>. Idempotent —
|
||||
/// repeat calls for the same alarm tag are a no-op. Subscribe failure for any of the
|
||||
/// four rolls back the alarm entry so a stale callback cannot promote a phantom.
|
||||
/// </summary>
|
||||
public async Task TrackAsync(string alarmTag)
|
||||
{
|
||||
if (_disposed || string.IsNullOrWhiteSpace(alarmTag)) return;
|
||||
if (_alarms.ContainsKey(alarmTag)) return;
|
||||
|
||||
var state = new AlarmState { AlarmTag = alarmTag };
|
||||
if (!_alarms.TryAdd(alarmTag, state)) return;
|
||||
|
||||
var probes = new[]
|
||||
{
|
||||
(Tag: alarmTag + InAlarmAttr, Field: AlarmField.InAlarm),
|
||||
(Tag: alarmTag + PriorityAttr, Field: AlarmField.Priority),
|
||||
(Tag: alarmTag + DescAttrNameAttr, Field: AlarmField.DescAttrName),
|
||||
(Tag: alarmTag + AckedAttr, Field: AlarmField.Acked),
|
||||
};
|
||||
|
||||
foreach (var p in probes)
|
||||
{
|
||||
_probeToAlarm[p.Tag] = (alarmTag, p.Field);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var p in probes)
|
||||
{
|
||||
await _subscribe(p.Tag, OnProbeCallback).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Rollback so a partial advise doesn't leak state.
|
||||
_alarms.TryRemove(alarmTag, out _);
|
||||
foreach (var p in probes)
|
||||
{
|
||||
_probeToAlarm.TryRemove(p.Tag, out _);
|
||||
try { await _unsubscribe(p.Tag).ConfigureAwait(false); } catch { }
|
||||
}
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drop every tracked alarm. Unadvises all 4 probes per alarm as best-effort.
|
||||
/// </summary>
|
||||
public async Task ClearAsync()
|
||||
{
|
||||
_alarms.Clear();
|
||||
foreach (var kv in _probeToAlarm.ToList())
|
||||
{
|
||||
_probeToAlarm.TryRemove(kv.Key, out _);
|
||||
try { await _unsubscribe(kv.Key).ConfigureAwait(false); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Operator ack — write the comment text into <c><alarmTag>.AckMsg</c>.
|
||||
/// Returns false when the runtime reports the write failed.
|
||||
/// </summary>
|
||||
public Task<bool> AcknowledgeAsync(string alarmTag, string comment)
|
||||
{
|
||||
if (_disposed || string.IsNullOrWhiteSpace(alarmTag))
|
||||
return Task.FromResult(false);
|
||||
return _write(alarmTag + AckMsgAttr, comment ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscription callback entry point. Exposed for tests and for the Backend to route
|
||||
/// fan-out callbacks through. Runs the state machine and fires TransitionRaised
|
||||
/// outside the lock.
|
||||
/// </summary>
|
||||
public void OnProbeCallback(string probeTag, Vtq vtq)
|
||||
{
|
||||
if (_disposed) return;
|
||||
if (!_probeToAlarm.TryGetValue(probeTag, out var link)) return;
|
||||
if (!_alarms.TryGetValue(link.AlarmTag, out var state)) return;
|
||||
|
||||
AlarmTransition? transition = null;
|
||||
var now = _clock();
|
||||
|
||||
lock (state.Lock)
|
||||
{
|
||||
switch (link.Field)
|
||||
{
|
||||
case AlarmField.InAlarm:
|
||||
{
|
||||
var wasActive = state.InAlarm;
|
||||
var isActive = vtq.Value is bool b && b;
|
||||
state.InAlarm = isActive;
|
||||
state.LastUpdateUtc = now;
|
||||
if (!wasActive && isActive)
|
||||
{
|
||||
state.Acked = false;
|
||||
state.LastTransitionUtc = now;
|
||||
transition = new AlarmTransition(state.AlarmTag, AlarmStateTransition.Active, state.Priority, state.DescAttrName, now);
|
||||
}
|
||||
else if (wasActive && !isActive)
|
||||
{
|
||||
state.LastTransitionUtc = now;
|
||||
transition = new AlarmTransition(state.AlarmTag, AlarmStateTransition.Inactive, state.Priority, state.DescAttrName, now);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case AlarmField.Priority:
|
||||
if (vtq.Value is int pi) state.Priority = pi;
|
||||
else if (vtq.Value is short ps) state.Priority = ps;
|
||||
else if (vtq.Value is long pl && pl <= int.MaxValue) state.Priority = (int)pl;
|
||||
state.LastUpdateUtc = now;
|
||||
break;
|
||||
case AlarmField.DescAttrName:
|
||||
state.DescAttrName = vtq.Value as string;
|
||||
state.LastUpdateUtc = now;
|
||||
break;
|
||||
case AlarmField.Acked:
|
||||
{
|
||||
var wasAcked = state.Acked;
|
||||
var isAcked = vtq.Value is bool b && b;
|
||||
state.Acked = isAcked;
|
||||
state.LastUpdateUtc = now;
|
||||
// Fire Acknowledged only when transitioning false→true. Don't fire on initial
|
||||
// subscribe callback (wasAcked==isAcked in that case because the state starts
|
||||
// with Acked=false and the initial probe is usually true for an un-active alarm).
|
||||
if (!wasAcked && isAcked && state.InAlarm)
|
||||
{
|
||||
state.LastTransitionUtc = now;
|
||||
transition = new AlarmTransition(state.AlarmTag, AlarmStateTransition.Acknowledged, state.Priority, state.DescAttrName, now);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (transition is { } t)
|
||||
{
|
||||
TransitionRaised?.Invoke(this, t);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<AlarmSnapshot> SnapshotStates()
|
||||
{
|
||||
return _alarms.Values.Select(s =>
|
||||
{
|
||||
lock (s.Lock)
|
||||
return new AlarmSnapshot(s.AlarmTag, s.InAlarm, s.Acked, s.Priority, s.DescAttrName);
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_alarms.Clear();
|
||||
_probeToAlarm.Clear();
|
||||
}
|
||||
|
||||
private sealed class AlarmState
|
||||
{
|
||||
public readonly object Lock = new();
|
||||
public string AlarmTag = "";
|
||||
public bool InAlarm;
|
||||
public bool Acked = true; // default ack'd so first false→true on subscribe doesn't misfire
|
||||
public int Priority;
|
||||
public string? DescAttrName;
|
||||
public DateTime LastUpdateUtc;
|
||||
public DateTime LastTransitionUtc;
|
||||
}
|
||||
|
||||
private enum AlarmField { InAlarm, Priority, DescAttrName, Acked }
|
||||
}
|
||||
|
||||
public enum AlarmStateTransition { Active, Acknowledged, Inactive }
|
||||
|
||||
public sealed record AlarmTransition(
|
||||
string AlarmTag,
|
||||
AlarmStateTransition Transition,
|
||||
int Priority,
|
||||
string? DescAttrName,
|
||||
DateTime AtUtc);
|
||||
|
||||
public sealed record AlarmSnapshot(
|
||||
string AlarmTag,
|
||||
bool InAlarm,
|
||||
bool Acked,
|
||||
int Priority,
|
||||
string? DescAttrName);
|
||||
@@ -21,6 +21,13 @@ public sealed class DbBackedGalaxyBackend(GalaxyRepository repository) : IGalaxy
|
||||
private long _nextSessionId;
|
||||
private long _nextSubscriptionId;
|
||||
|
||||
// DB-only backend doesn't have a runtime data plane; never raises events.
|
||||
#pragma warning disable CS0067
|
||||
public event System.EventHandler<OnDataChangeNotification>? OnDataChange;
|
||||
public event System.EventHandler<GalaxyAlarmEvent>? OnAlarmEvent;
|
||||
public event System.EventHandler<HostConnectivityStatus>? OnHostStatusChanged;
|
||||
#pragma warning restore CS0067
|
||||
|
||||
public Task<OpenSessionResponse> OpenSessionAsync(OpenSessionRequest req, CancellationToken ct)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _nextSessionId);
|
||||
@@ -120,6 +127,33 @@ public sealed class DbBackedGalaxyBackend(GalaxyRepository repository) : IGalaxy
|
||||
Tags = System.Array.Empty<HistoryTagValues>(),
|
||||
});
|
||||
|
||||
public Task<HistoryReadProcessedResponse> HistoryReadProcessedAsync(
|
||||
HistoryReadProcessedRequest req, CancellationToken ct)
|
||||
=> Task.FromResult(new HistoryReadProcessedResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "MXAccess + Historian code lift pending (Phase 2 Task B.1)",
|
||||
Values = System.Array.Empty<GalaxyDataValue>(),
|
||||
});
|
||||
|
||||
public Task<HistoryReadAtTimeResponse> HistoryReadAtTimeAsync(
|
||||
HistoryReadAtTimeRequest req, CancellationToken ct)
|
||||
=> Task.FromResult(new HistoryReadAtTimeResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "MXAccess + Historian code lift pending (Phase 2 Task B.1)",
|
||||
Values = System.Array.Empty<GalaxyDataValue>(),
|
||||
});
|
||||
|
||||
public Task<HistoryReadEventsResponse> HistoryReadEventsAsync(
|
||||
HistoryReadEventsRequest req, CancellationToken ct)
|
||||
=> Task.FromResult(new HistoryReadEventsResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "MXAccess + Historian code lift pending (Phase 2 Task B.1)",
|
||||
Events = System.Array.Empty<GalaxyHistoricalEvent>(),
|
||||
});
|
||||
|
||||
public Task<RecycleStatusResponse> RecycleAsync(RecycleHostRequest req, CancellationToken ct)
|
||||
=> Task.FromResult(new RecycleStatusResponse { Accepted = true, GraceSeconds = 15 });
|
||||
|
||||
@@ -131,6 +165,7 @@ public sealed class DbBackedGalaxyBackend(GalaxyRepository repository) : IGalaxy
|
||||
ArrayDim = row.ArrayDimension is int d and > 0 ? (uint)d : null,
|
||||
SecurityClassification = row.SecurityClassification,
|
||||
IsHistorized = row.IsHistorized,
|
||||
IsAlarm = row.IsAlarm,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian
|
||||
{
|
||||
/// <summary>
|
||||
/// Thread-safe, pure-logic endpoint picker for the Wonderware Historian cluster. Tracks which
|
||||
/// configured nodes are healthy, places failed nodes in a time-bounded cooldown, and hands
|
||||
/// out an ordered list of eligible candidates for the data source to try in sequence.
|
||||
/// </summary>
|
||||
internal sealed class HistorianClusterEndpointPicker
|
||||
{
|
||||
private readonly Func<DateTime> _clock;
|
||||
private readonly TimeSpan _cooldown;
|
||||
private readonly object _lock = new object();
|
||||
private readonly List<NodeEntry> _nodes;
|
||||
|
||||
public HistorianClusterEndpointPicker(HistorianConfiguration config)
|
||||
: this(config, () => DateTime.UtcNow) { }
|
||||
|
||||
internal HistorianClusterEndpointPicker(HistorianConfiguration config, Func<DateTime> clock)
|
||||
{
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_cooldown = TimeSpan.FromSeconds(Math.Max(0, config.FailureCooldownSeconds));
|
||||
|
||||
var names = (config.ServerNames != null && config.ServerNames.Count > 0)
|
||||
? config.ServerNames
|
||||
: new List<string> { config.ServerName };
|
||||
|
||||
_nodes = names
|
||||
.Where(n => !string.IsNullOrWhiteSpace(n))
|
||||
.Select(n => n.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Select(n => new NodeEntry { Name = n })
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public int NodeCount
|
||||
{
|
||||
get { lock (_lock) return _nodes.Count; }
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> GetHealthyNodes()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var now = _clock();
|
||||
return _nodes.Where(n => IsHealthyAt(n, now)).Select(n => n.Name).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public int HealthyNodeCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var now = _clock();
|
||||
return _nodes.Count(n => IsHealthyAt(n, now));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkFailed(string node, string? error)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var entry = FindEntry(node);
|
||||
if (entry == null) return;
|
||||
|
||||
var now = _clock();
|
||||
entry.FailureCount++;
|
||||
entry.LastError = error;
|
||||
entry.LastFailureTime = now;
|
||||
entry.CooldownUntil = _cooldown.TotalMilliseconds > 0 ? now + _cooldown : (DateTime?)null;
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkHealthy(string node)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var entry = FindEntry(node);
|
||||
if (entry == null) return;
|
||||
entry.CooldownUntil = null;
|
||||
}
|
||||
}
|
||||
|
||||
public List<HistorianClusterNodeState> SnapshotNodeStates()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var now = _clock();
|
||||
return _nodes.Select(n => new HistorianClusterNodeState
|
||||
{
|
||||
Name = n.Name,
|
||||
IsHealthy = IsHealthyAt(n, now),
|
||||
CooldownUntil = IsHealthyAt(n, now) ? null : n.CooldownUntil,
|
||||
FailureCount = n.FailureCount,
|
||||
LastError = n.LastError,
|
||||
LastFailureTime = n.LastFailureTime
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsHealthyAt(NodeEntry entry, DateTime now)
|
||||
{
|
||||
return entry.CooldownUntil == null || entry.CooldownUntil <= now;
|
||||
}
|
||||
|
||||
private NodeEntry? FindEntry(string node)
|
||||
{
|
||||
for (var i = 0; i < _nodes.Count; i++)
|
||||
if (string.Equals(_nodes[i].Name, node, StringComparison.OrdinalIgnoreCase))
|
||||
return _nodes[i];
|
||||
return null;
|
||||
}
|
||||
|
||||
private sealed class NodeEntry
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public DateTime? CooldownUntil { get; set; }
|
||||
public int FailureCount { get; set; }
|
||||
public string? LastError { get; set; }
|
||||
public DateTime? LastFailureTime { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian
|
||||
{
|
||||
/// <summary>
|
||||
/// Point-in-time state of a single historian cluster node. One entry per configured node
|
||||
/// appears inside <see cref="HistorianHealthSnapshot"/>.
|
||||
/// </summary>
|
||||
public sealed class HistorianClusterNodeState
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public bool IsHealthy { get; set; }
|
||||
public DateTime? CooldownUntil { get; set; }
|
||||
public int FailureCount { get; set; }
|
||||
public string? LastError { get; set; }
|
||||
public DateTime? LastFailureTime { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian
|
||||
{
|
||||
/// <summary>
|
||||
/// Wonderware Historian SDK configuration. Populated from environment variables at Host
|
||||
/// startup (see <c>Program.cs</c>) or from the Proxy's <c>DriverInstance.DriverConfig</c>
|
||||
/// section passed during OpenSession. Kept OPC-UA-free — the Proxy side owns UA translation.
|
||||
/// </summary>
|
||||
public sealed class HistorianConfiguration
|
||||
{
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
/// <summary>Single-node fallback when <see cref="ServerNames"/> is empty.</summary>
|
||||
public string ServerName { get; set; } = "localhost";
|
||||
|
||||
/// <summary>
|
||||
/// Ordered cluster nodes. When non-empty, the data source tries each in order on connect,
|
||||
/// falling through to the next on failure. A failed node is placed in cooldown for
|
||||
/// <see cref="FailureCooldownSeconds"/> before being re-eligible.
|
||||
/// </summary>
|
||||
public List<string> ServerNames { get; set; } = new();
|
||||
|
||||
public int FailureCooldownSeconds { get; set; } = 60;
|
||||
public bool IntegratedSecurity { get; set; } = true;
|
||||
public string? UserName { get; set; }
|
||||
public string? Password { get; set; }
|
||||
public int Port { get; set; } = 32568;
|
||||
public int CommandTimeoutSeconds { get; set; } = 30;
|
||||
public int MaxValuesPerRead { get; set; } = 10000;
|
||||
|
||||
/// <summary>
|
||||
/// Outer safety timeout applied to sync-over-async Historian operations. Must be
|
||||
/// comfortably larger than <see cref="CommandTimeoutSeconds"/>.
|
||||
/// </summary>
|
||||
public int RequestTimeoutSeconds { get; set; } = 60;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StringCollection = System.Collections.Specialized.StringCollection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ArchestrA;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads historical data from the Wonderware Historian via the aahClientManaged SDK.
|
||||
/// OPC-UA-free — emits <see cref="HistorianSample"/>/<see cref="HistorianAggregateSample"/>
|
||||
/// which the Proxy maps to OPC UA <c>DataValue</c> on its side of the IPC.
|
||||
/// </summary>
|
||||
public sealed class HistorianDataSource : IHistorianDataSource
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<HistorianDataSource>();
|
||||
|
||||
private readonly HistorianConfiguration _config;
|
||||
private readonly object _connectionLock = new object();
|
||||
private readonly object _eventConnectionLock = new object();
|
||||
private readonly IHistorianConnectionFactory _factory;
|
||||
private HistorianAccess? _connection;
|
||||
private HistorianAccess? _eventConnection;
|
||||
private bool _disposed;
|
||||
|
||||
private readonly object _healthLock = new object();
|
||||
private long _totalSuccesses;
|
||||
private long _totalFailures;
|
||||
private int _consecutiveFailures;
|
||||
private DateTime? _lastSuccessTime;
|
||||
private DateTime? _lastFailureTime;
|
||||
private string? _lastError;
|
||||
private string? _activeProcessNode;
|
||||
private string? _activeEventNode;
|
||||
|
||||
private readonly HistorianClusterEndpointPicker _picker;
|
||||
|
||||
public HistorianDataSource(HistorianConfiguration config)
|
||||
: this(config, new SdkHistorianConnectionFactory(), null) { }
|
||||
|
||||
internal HistorianDataSource(
|
||||
HistorianConfiguration config,
|
||||
IHistorianConnectionFactory factory,
|
||||
HistorianClusterEndpointPicker? picker = null)
|
||||
{
|
||||
_config = config;
|
||||
_factory = factory;
|
||||
_picker = picker ?? new HistorianClusterEndpointPicker(config);
|
||||
}
|
||||
|
||||
private (HistorianAccess Connection, string Node) ConnectToAnyHealthyNode(HistorianConnectionType type)
|
||||
{
|
||||
var candidates = _picker.GetHealthyNodes();
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
var total = _picker.NodeCount;
|
||||
throw new InvalidOperationException(
|
||||
total == 0
|
||||
? "No historian nodes configured"
|
||||
: $"All {total} historian nodes are in cooldown — no healthy endpoints to connect to");
|
||||
}
|
||||
|
||||
Exception? lastException = null;
|
||||
foreach (var node in candidates)
|
||||
{
|
||||
var attemptConfig = CloneConfigWithServerName(node);
|
||||
try
|
||||
{
|
||||
var conn = _factory.CreateAndConnect(attemptConfig, type);
|
||||
_picker.MarkHealthy(node);
|
||||
return (conn, node);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_picker.MarkFailed(node, ex.Message);
|
||||
lastException = ex;
|
||||
Log.Warning(ex, "Historian node {Node} failed during connect attempt; trying next candidate", node);
|
||||
}
|
||||
}
|
||||
|
||||
var inner = lastException?.Message ?? "(no detail)";
|
||||
throw new InvalidOperationException(
|
||||
$"All {candidates.Count} healthy historian candidate(s) failed during connect: {inner}",
|
||||
lastException);
|
||||
}
|
||||
|
||||
private HistorianConfiguration CloneConfigWithServerName(string serverName)
|
||||
{
|
||||
return new HistorianConfiguration
|
||||
{
|
||||
Enabled = _config.Enabled,
|
||||
ServerName = serverName,
|
||||
ServerNames = _config.ServerNames,
|
||||
FailureCooldownSeconds = _config.FailureCooldownSeconds,
|
||||
IntegratedSecurity = _config.IntegratedSecurity,
|
||||
UserName = _config.UserName,
|
||||
Password = _config.Password,
|
||||
Port = _config.Port,
|
||||
CommandTimeoutSeconds = _config.CommandTimeoutSeconds,
|
||||
MaxValuesPerRead = _config.MaxValuesPerRead
|
||||
};
|
||||
}
|
||||
|
||||
public HistorianHealthSnapshot GetHealthSnapshot()
|
||||
{
|
||||
var nodeStates = _picker.SnapshotNodeStates();
|
||||
var healthyCount = 0;
|
||||
foreach (var n in nodeStates)
|
||||
if (n.IsHealthy) healthyCount++;
|
||||
|
||||
lock (_healthLock)
|
||||
{
|
||||
return new HistorianHealthSnapshot
|
||||
{
|
||||
TotalQueries = _totalSuccesses + _totalFailures,
|
||||
TotalSuccesses = _totalSuccesses,
|
||||
TotalFailures = _totalFailures,
|
||||
ConsecutiveFailures = _consecutiveFailures,
|
||||
LastSuccessTime = _lastSuccessTime,
|
||||
LastFailureTime = _lastFailureTime,
|
||||
LastError = _lastError,
|
||||
ProcessConnectionOpen = Volatile.Read(ref _connection) != null,
|
||||
EventConnectionOpen = Volatile.Read(ref _eventConnection) != null,
|
||||
ActiveProcessNode = _activeProcessNode,
|
||||
ActiveEventNode = _activeEventNode,
|
||||
NodeCount = nodeStates.Count,
|
||||
HealthyNodeCount = healthyCount,
|
||||
Nodes = nodeStates
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordSuccess()
|
||||
{
|
||||
lock (_healthLock)
|
||||
{
|
||||
_totalSuccesses++;
|
||||
_lastSuccessTime = DateTime.UtcNow;
|
||||
_consecutiveFailures = 0;
|
||||
_lastError = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordFailure(string error)
|
||||
{
|
||||
lock (_healthLock)
|
||||
{
|
||||
_totalFailures++;
|
||||
_lastFailureTime = DateTime.UtcNow;
|
||||
_consecutiveFailures++;
|
||||
_lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureConnected()
|
||||
{
|
||||
if (_disposed)
|
||||
throw new ObjectDisposedException(nameof(HistorianDataSource));
|
||||
|
||||
if (Volatile.Read(ref _connection) != null) return;
|
||||
|
||||
var (conn, winningNode) = ConnectToAnyHealthyNode(HistorianConnectionType.Process);
|
||||
|
||||
lock (_connectionLock)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
conn.CloseConnection(out _);
|
||||
conn.Dispose();
|
||||
throw new ObjectDisposedException(nameof(HistorianDataSource));
|
||||
}
|
||||
|
||||
if (_connection != null)
|
||||
{
|
||||
conn.CloseConnection(out _);
|
||||
conn.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
_connection = conn;
|
||||
lock (_healthLock) _activeProcessNode = winningNode;
|
||||
Log.Information("Historian SDK connection opened to {Server}:{Port}", winningNode, _config.Port);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleConnectionError(Exception? ex = null)
|
||||
{
|
||||
lock (_connectionLock)
|
||||
{
|
||||
if (_connection == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
_connection.CloseConnection(out _);
|
||||
_connection.Dispose();
|
||||
}
|
||||
catch (Exception disposeEx)
|
||||
{
|
||||
Log.Debug(disposeEx, "Error disposing Historian SDK connection during error recovery");
|
||||
}
|
||||
|
||||
_connection = null;
|
||||
string? failedNode;
|
||||
lock (_healthLock)
|
||||
{
|
||||
failedNode = _activeProcessNode;
|
||||
_activeProcessNode = null;
|
||||
}
|
||||
|
||||
if (failedNode != null) _picker.MarkFailed(failedNode, ex?.Message ?? "mid-query failure");
|
||||
Log.Warning(ex, "Historian SDK connection reset (node={Node})", failedNode ?? "(unknown)");
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureEventConnected()
|
||||
{
|
||||
if (_disposed)
|
||||
throw new ObjectDisposedException(nameof(HistorianDataSource));
|
||||
|
||||
if (Volatile.Read(ref _eventConnection) != null) return;
|
||||
|
||||
var (conn, winningNode) = ConnectToAnyHealthyNode(HistorianConnectionType.Event);
|
||||
|
||||
lock (_eventConnectionLock)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
conn.CloseConnection(out _);
|
||||
conn.Dispose();
|
||||
throw new ObjectDisposedException(nameof(HistorianDataSource));
|
||||
}
|
||||
|
||||
if (_eventConnection != null)
|
||||
{
|
||||
conn.CloseConnection(out _);
|
||||
conn.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
_eventConnection = conn;
|
||||
lock (_healthLock) _activeEventNode = winningNode;
|
||||
Log.Information("Historian SDK event connection opened to {Server}:{Port}", winningNode, _config.Port);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleEventConnectionError(Exception? ex = null)
|
||||
{
|
||||
lock (_eventConnectionLock)
|
||||
{
|
||||
if (_eventConnection == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
_eventConnection.CloseConnection(out _);
|
||||
_eventConnection.Dispose();
|
||||
}
|
||||
catch (Exception disposeEx)
|
||||
{
|
||||
Log.Debug(disposeEx, "Error disposing Historian SDK event connection during error recovery");
|
||||
}
|
||||
|
||||
_eventConnection = null;
|
||||
string? failedNode;
|
||||
lock (_healthLock)
|
||||
{
|
||||
failedNode = _activeEventNode;
|
||||
_activeEventNode = null;
|
||||
}
|
||||
|
||||
if (failedNode != null) _picker.MarkFailed(failedNode, ex?.Message ?? "mid-query failure");
|
||||
Log.Warning(ex, "Historian SDK event connection reset (node={Node})", failedNode ?? "(unknown)");
|
||||
}
|
||||
}
|
||||
|
||||
public Task<List<HistorianSample>> ReadRawAsync(
|
||||
string tagName, DateTime startTime, DateTime endTime, int maxValues,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var results = new List<HistorianSample>();
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConnected();
|
||||
|
||||
using var query = _connection!.CreateHistoryQuery();
|
||||
var args = new HistoryQueryArgs
|
||||
{
|
||||
TagNames = new StringCollection { tagName },
|
||||
StartDateTime = startTime,
|
||||
EndDateTime = endTime,
|
||||
RetrievalMode = HistorianRetrievalMode.Full
|
||||
};
|
||||
|
||||
if (maxValues > 0)
|
||||
args.BatchSize = (uint)maxValues;
|
||||
else if (_config.MaxValuesPerRead > 0)
|
||||
args.BatchSize = (uint)_config.MaxValuesPerRead;
|
||||
|
||||
if (!query.StartQuery(args, out var error))
|
||||
{
|
||||
Log.Warning("Historian SDK raw query start failed for {Tag}: {Error}", tagName, error.ErrorCode);
|
||||
RecordFailure($"raw StartQuery: {error.ErrorCode}");
|
||||
HandleConnectionError();
|
||||
return Task.FromResult(results);
|
||||
}
|
||||
|
||||
var count = 0;
|
||||
var limit = maxValues > 0 ? maxValues : _config.MaxValuesPerRead;
|
||||
|
||||
while (query.MoveNext(out error))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var result = query.QueryResult;
|
||||
var timestamp = DateTime.SpecifyKind(result.StartDateTime, DateTimeKind.Utc);
|
||||
|
||||
object? value;
|
||||
if (!string.IsNullOrEmpty(result.StringValue) && result.Value == 0)
|
||||
value = result.StringValue;
|
||||
else
|
||||
value = result.Value;
|
||||
|
||||
results.Add(new HistorianSample
|
||||
{
|
||||
Value = value,
|
||||
TimestampUtc = timestamp,
|
||||
Quality = (byte)(result.OpcQuality & 0xFF),
|
||||
});
|
||||
|
||||
count++;
|
||||
if (limit > 0 && count >= limit) break;
|
||||
}
|
||||
|
||||
query.EndQuery(out _);
|
||||
RecordSuccess();
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (ObjectDisposedException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "HistoryRead raw failed for {Tag}", tagName);
|
||||
RecordFailure($"raw: {ex.Message}");
|
||||
HandleConnectionError(ex);
|
||||
}
|
||||
|
||||
Log.Debug("HistoryRead raw: {Tag} returned {Count} values ({Start} to {End})",
|
||||
tagName, results.Count, startTime, endTime);
|
||||
|
||||
return Task.FromResult(results);
|
||||
}
|
||||
|
||||
public Task<List<HistorianAggregateSample>> ReadAggregateAsync(
|
||||
string tagName, DateTime startTime, DateTime endTime,
|
||||
double intervalMs, string aggregateColumn,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var results = new List<HistorianAggregateSample>();
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConnected();
|
||||
|
||||
using var query = _connection!.CreateAnalogSummaryQuery();
|
||||
var args = new AnalogSummaryQueryArgs
|
||||
{
|
||||
TagNames = new StringCollection { tagName },
|
||||
StartDateTime = startTime,
|
||||
EndDateTime = endTime,
|
||||
Resolution = (ulong)intervalMs
|
||||
};
|
||||
|
||||
if (!query.StartQuery(args, out var error))
|
||||
{
|
||||
Log.Warning("Historian SDK aggregate query start failed for {Tag}: {Error}", tagName, error.ErrorCode);
|
||||
RecordFailure($"aggregate StartQuery: {error.ErrorCode}");
|
||||
HandleConnectionError();
|
||||
return Task.FromResult(results);
|
||||
}
|
||||
|
||||
while (query.MoveNext(out error))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var result = query.QueryResult;
|
||||
var timestamp = DateTime.SpecifyKind(result.StartDateTime, DateTimeKind.Utc);
|
||||
var value = ExtractAggregateValue(result, aggregateColumn);
|
||||
|
||||
results.Add(new HistorianAggregateSample
|
||||
{
|
||||
Value = value,
|
||||
TimestampUtc = timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
query.EndQuery(out _);
|
||||
RecordSuccess();
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (ObjectDisposedException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "HistoryRead aggregate failed for {Tag}", tagName);
|
||||
RecordFailure($"aggregate: {ex.Message}");
|
||||
HandleConnectionError(ex);
|
||||
}
|
||||
|
||||
Log.Debug("HistoryRead aggregate ({Aggregate}): {Tag} returned {Count} values",
|
||||
aggregateColumn, tagName, results.Count);
|
||||
|
||||
return Task.FromResult(results);
|
||||
}
|
||||
|
||||
public Task<List<HistorianSample>> ReadAtTimeAsync(
|
||||
string tagName, DateTime[] timestamps,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var results = new List<HistorianSample>();
|
||||
|
||||
if (timestamps == null || timestamps.Length == 0)
|
||||
return Task.FromResult(results);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConnected();
|
||||
|
||||
foreach (var timestamp in timestamps)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
using var query = _connection!.CreateHistoryQuery();
|
||||
var args = new HistoryQueryArgs
|
||||
{
|
||||
TagNames = new StringCollection { tagName },
|
||||
StartDateTime = timestamp,
|
||||
EndDateTime = timestamp,
|
||||
RetrievalMode = HistorianRetrievalMode.Interpolated,
|
||||
BatchSize = 1
|
||||
};
|
||||
|
||||
if (!query.StartQuery(args, out var error))
|
||||
{
|
||||
results.Add(new HistorianSample
|
||||
{
|
||||
Value = null,
|
||||
TimestampUtc = DateTime.SpecifyKind(timestamp, DateTimeKind.Utc),
|
||||
Quality = 0, // Bad
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (query.MoveNext(out error))
|
||||
{
|
||||
var result = query.QueryResult;
|
||||
object? value;
|
||||
if (!string.IsNullOrEmpty(result.StringValue) && result.Value == 0)
|
||||
value = result.StringValue;
|
||||
else
|
||||
value = result.Value;
|
||||
|
||||
results.Add(new HistorianSample
|
||||
{
|
||||
Value = value,
|
||||
TimestampUtc = DateTime.SpecifyKind(timestamp, DateTimeKind.Utc),
|
||||
Quality = (byte)(result.OpcQuality & 0xFF),
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
results.Add(new HistorianSample
|
||||
{
|
||||
Value = null,
|
||||
TimestampUtc = DateTime.SpecifyKind(timestamp, DateTimeKind.Utc),
|
||||
Quality = 0,
|
||||
});
|
||||
}
|
||||
|
||||
query.EndQuery(out _);
|
||||
}
|
||||
RecordSuccess();
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (ObjectDisposedException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "HistoryRead at-time failed for {Tag}", tagName);
|
||||
RecordFailure($"at-time: {ex.Message}");
|
||||
HandleConnectionError(ex);
|
||||
}
|
||||
|
||||
Log.Debug("HistoryRead at-time: {Tag} returned {Count} values for {Timestamps} timestamps",
|
||||
tagName, results.Count, timestamps.Length);
|
||||
|
||||
return Task.FromResult(results);
|
||||
}
|
||||
|
||||
public Task<List<HistorianEventDto>> ReadEventsAsync(
|
||||
string? sourceName, DateTime startTime, DateTime endTime, int maxEvents,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var results = new List<HistorianEventDto>();
|
||||
|
||||
try
|
||||
{
|
||||
EnsureEventConnected();
|
||||
|
||||
using var query = _eventConnection!.CreateEventQuery();
|
||||
var args = new EventQueryArgs
|
||||
{
|
||||
StartDateTime = startTime,
|
||||
EndDateTime = endTime,
|
||||
EventCount = maxEvents > 0 ? (uint)maxEvents : (uint)_config.MaxValuesPerRead,
|
||||
QueryType = HistorianEventQueryType.Events,
|
||||
EventOrder = HistorianEventOrder.Ascending
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(sourceName))
|
||||
{
|
||||
query.AddEventFilter("Source", HistorianComparisionType.Equal, sourceName, out _);
|
||||
}
|
||||
|
||||
if (!query.StartQuery(args, out var error))
|
||||
{
|
||||
Log.Warning("Historian SDK event query start failed: {Error}", error.ErrorCode);
|
||||
RecordFailure($"events StartQuery: {error.ErrorCode}");
|
||||
HandleEventConnectionError();
|
||||
return Task.FromResult(results);
|
||||
}
|
||||
|
||||
var count = 0;
|
||||
while (query.MoveNext(out error))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
results.Add(ToDto(query.QueryResult));
|
||||
count++;
|
||||
if (maxEvents > 0 && count >= maxEvents) break;
|
||||
}
|
||||
|
||||
query.EndQuery(out _);
|
||||
RecordSuccess();
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (ObjectDisposedException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "HistoryRead events failed for source {Source}", sourceName ?? "(all)");
|
||||
RecordFailure($"events: {ex.Message}");
|
||||
HandleEventConnectionError(ex);
|
||||
}
|
||||
|
||||
Log.Debug("HistoryRead events: source={Source} returned {Count} events ({Start} to {End})",
|
||||
sourceName ?? "(all)", results.Count, startTime, endTime);
|
||||
|
||||
return Task.FromResult(results);
|
||||
}
|
||||
|
||||
private static HistorianEventDto ToDto(HistorianEvent evt)
|
||||
{
|
||||
// The ArchestrA SDK marks these properties obsolete but still returns them; their
|
||||
// successors aren't wired in the version we bind against. Using them is the documented
|
||||
// v1 behavior — suppressed locally instead of project-wide so any non-event use of
|
||||
// deprecated SDK surface still surfaces as an error.
|
||||
#pragma warning disable CS0618
|
||||
return new HistorianEventDto
|
||||
{
|
||||
Id = evt.Id,
|
||||
Source = evt.Source,
|
||||
EventTime = evt.EventTime,
|
||||
ReceivedTime = evt.ReceivedTime,
|
||||
DisplayText = evt.DisplayText,
|
||||
Severity = (ushort)evt.Severity
|
||||
};
|
||||
#pragma warning restore CS0618
|
||||
}
|
||||
|
||||
internal static double? ExtractAggregateValue(AnalogSummaryQueryResult result, string column)
|
||||
{
|
||||
switch (column)
|
||||
{
|
||||
case "Average": return result.Average;
|
||||
case "Minimum": return result.Minimum;
|
||||
case "Maximum": return result.Maximum;
|
||||
case "ValueCount": return result.ValueCount;
|
||||
case "First": return result.First;
|
||||
case "Last": return result.Last;
|
||||
case "StdDev": return result.StdDev;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
try
|
||||
{
|
||||
_connection?.CloseConnection(out _);
|
||||
_connection?.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error closing Historian SDK connection");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_eventConnection?.CloseConnection(out _);
|
||||
_eventConnection?.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error closing Historian SDK event connection");
|
||||
}
|
||||
|
||||
_connection = null;
|
||||
_eventConnection = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian
|
||||
{
|
||||
/// <summary>
|
||||
/// SDK-free representation of a Historian event record. Prevents ArchestrA types from
|
||||
/// leaking beyond <c>HistorianDataSource</c>.
|
||||
/// </summary>
|
||||
public sealed class HistorianEventDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string? Source { get; set; }
|
||||
public DateTime EventTime { get; set; }
|
||||
public DateTime ReceivedTime { get; set; }
|
||||
public string? DisplayText { get; set; }
|
||||
public ushort Severity { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian
|
||||
{
|
||||
/// <summary>
|
||||
/// Point-in-time runtime health of the historian subsystem — consumed by the status dashboard
|
||||
/// via an IPC health query (not wired in PR #5; deferred).
|
||||
/// </summary>
|
||||
public sealed class HistorianHealthSnapshot
|
||||
{
|
||||
public long TotalQueries { get; set; }
|
||||
public long TotalSuccesses { get; set; }
|
||||
public long TotalFailures { get; set; }
|
||||
public int ConsecutiveFailures { get; set; }
|
||||
public DateTime? LastSuccessTime { get; set; }
|
||||
public DateTime? LastFailureTime { get; set; }
|
||||
public string? LastError { get; set; }
|
||||
public bool ProcessConnectionOpen { get; set; }
|
||||
public bool EventConnectionOpen { get; set; }
|
||||
public string? ActiveProcessNode { get; set; }
|
||||
public string? ActiveEventNode { get; set; }
|
||||
public int NodeCount { get; set; }
|
||||
public int HealthyNodeCount { get; set; }
|
||||
public List<HistorianClusterNodeState> Nodes { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a raw OPC DA quality byte (as returned by Wonderware Historian's <c>OpcQuality</c>)
|
||||
/// to an OPC UA <c>StatusCode</c> uint. Preserves specific codes (BadNotConnected,
|
||||
/// UncertainSubNormal, etc.) instead of collapsing to Good/Uncertain/Bad categories.
|
||||
/// Mirrors v1 <c>QualityMapper.MapToOpcUaStatusCode</c> without pulling in OPC UA types —
|
||||
/// the returned value is the 32-bit OPC UA <c>StatusCode</c> wire encoding that the Proxy
|
||||
/// surfaces directly as <c>DataValueSnapshot.StatusCode</c>.
|
||||
/// </summary>
|
||||
public static class HistorianQualityMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Map an 8-bit OPC DA quality byte to the corresponding OPC UA StatusCode. The byte
|
||||
/// family bits decide the category (Good >= 192, Uncertain 64-191, Bad 0-63); the
|
||||
/// low-nibble subcode selects the specific code.
|
||||
/// </summary>
|
||||
public static uint Map(byte q) => q switch
|
||||
{
|
||||
// Good family (192+)
|
||||
192 => 0x00000000u, // Good
|
||||
216 => 0x00D80000u, // Good_LocalOverride
|
||||
|
||||
// Uncertain family (64-191)
|
||||
64 => 0x40000000u, // Uncertain
|
||||
68 => 0x40900000u, // Uncertain_LastUsableValue
|
||||
80 => 0x40930000u, // Uncertain_SensorNotAccurate
|
||||
84 => 0x40940000u, // Uncertain_EngineeringUnitsExceeded
|
||||
88 => 0x40950000u, // Uncertain_SubNormal
|
||||
|
||||
// Bad family (0-63)
|
||||
0 => 0x80000000u, // Bad
|
||||
4 => 0x80890000u, // Bad_ConfigurationError
|
||||
8 => 0x808A0000u, // Bad_NotConnected
|
||||
12 => 0x808B0000u, // Bad_DeviceFailure
|
||||
16 => 0x808C0000u, // Bad_SensorFailure
|
||||
20 => 0x80050000u, // Bad_CommunicationError
|
||||
24 => 0x808D0000u, // Bad_OutOfService
|
||||
32 => 0x80320000u, // Bad_WaitingForInitialData
|
||||
|
||||
// Unknown code — fall back to the category so callers still get a sensible bucket.
|
||||
_ when q >= 192 => 0x00000000u,
|
||||
_ when q >= 64 => 0x40000000u,
|
||||
_ => 0x80000000u,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian
|
||||
{
|
||||
/// <summary>
|
||||
/// OPC-UA-free representation of a single historical data point. The Host returns these
|
||||
/// across the IPC boundary as <c>GalaxyDataValue</c>; the Proxy maps quality and value to
|
||||
/// OPC UA <c>DataValue</c>. Raw MX quality byte is preserved so the Proxy can use the same
|
||||
/// quality mapper it already uses for live reads.
|
||||
/// </summary>
|
||||
public sealed class HistorianSample
|
||||
{
|
||||
public object? Value { get; set; }
|
||||
|
||||
/// <summary>Raw OPC DA quality byte from the historian SDK (low 8 bits of OpcQuality).</summary>
|
||||
public byte Quality { get; set; }
|
||||
|
||||
public DateTime TimestampUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of <see cref="IHistorianDataSource.ReadAggregateAsync"/>. When <see cref="Value"/> is
|
||||
/// null the aggregate is unavailable for that bucket (Proxy maps to <c>BadNoData</c>).
|
||||
/// </summary>
|
||||
public sealed class HistorianAggregateSample
|
||||
{
|
||||
public double? Value { get; set; }
|
||||
public DateTime TimestampUtc { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using ArchestrA;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates and opens Historian SDK connections. Extracted so tests can inject fakes that
|
||||
/// control connection success, failure, and timeout behavior.
|
||||
/// </summary>
|
||||
internal interface IHistorianConnectionFactory
|
||||
{
|
||||
HistorianAccess CreateAndConnect(HistorianConfiguration config, HistorianConnectionType type);
|
||||
}
|
||||
|
||||
/// <summary>Production implementation — opens real Historian SDK connections.</summary>
|
||||
internal sealed class SdkHistorianConnectionFactory : IHistorianConnectionFactory
|
||||
{
|
||||
public HistorianAccess CreateAndConnect(HistorianConfiguration config, HistorianConnectionType type)
|
||||
{
|
||||
var conn = new HistorianAccess();
|
||||
|
||||
var args = new HistorianConnectionArgs
|
||||
{
|
||||
ServerName = config.ServerName,
|
||||
TcpPort = (ushort)config.Port,
|
||||
IntegratedSecurity = config.IntegratedSecurity,
|
||||
UseArchestrAUser = config.IntegratedSecurity,
|
||||
ConnectionType = type,
|
||||
ReadOnly = true,
|
||||
PacketTimeout = (uint)(config.CommandTimeoutSeconds * 1000)
|
||||
};
|
||||
|
||||
if (!config.IntegratedSecurity)
|
||||
{
|
||||
args.UserName = config.UserName ?? string.Empty;
|
||||
args.Password = config.Password ?? string.Empty;
|
||||
}
|
||||
|
||||
if (!conn.OpenConnection(args, out var error))
|
||||
{
|
||||
conn.Dispose();
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to open Historian SDK connection to {config.ServerName}:{config.Port}: {error.ErrorCode}");
|
||||
}
|
||||
|
||||
var timeoutMs = config.CommandTimeoutSeconds * 1000;
|
||||
var elapsed = 0;
|
||||
while (elapsed < timeoutMs)
|
||||
{
|
||||
var status = new HistorianConnectionStatus();
|
||||
conn.GetConnectionStatus(ref status);
|
||||
|
||||
if (status.ConnectedToServer)
|
||||
return conn;
|
||||
|
||||
if (status.ErrorOccurred)
|
||||
{
|
||||
conn.Dispose();
|
||||
throw new InvalidOperationException(
|
||||
$"Historian SDK connection failed: {status.Error}");
|
||||
}
|
||||
|
||||
Thread.Sleep(250);
|
||||
elapsed += 250;
|
||||
}
|
||||
|
||||
conn.Dispose();
|
||||
throw new TimeoutException(
|
||||
$"Historian SDK connection to {config.ServerName}:{config.Port} timed out after {config.CommandTimeoutSeconds}s");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian
|
||||
{
|
||||
/// <summary>
|
||||
/// OPC-UA-free surface for the Wonderware Historian subsystem inside Galaxy.Host.
|
||||
/// Implementations read via the aahClient* SDK; the Proxy side maps returned samples
|
||||
/// to OPC UA <c>DataValue</c>.
|
||||
/// </summary>
|
||||
public interface IHistorianDataSource : IDisposable
|
||||
{
|
||||
Task<List<HistorianSample>> ReadRawAsync(
|
||||
string tagName, DateTime startTime, DateTime endTime, int maxValues,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<List<HistorianAggregateSample>> ReadAggregateAsync(
|
||||
string tagName, DateTime startTime, DateTime endTime,
|
||||
double intervalMs, string aggregateColumn,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<List<HistorianSample>> ReadAtTimeAsync(
|
||||
string tagName, DateTime[] timestamps,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<List<HistorianEventDto>> ReadEventsAsync(
|
||||
string? sourceName, DateTime startTime, DateTime endTime, int maxEvents,
|
||||
CancellationToken ct = default);
|
||||
|
||||
HistorianHealthSnapshot GetHealthSnapshot();
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||
/// </summary>
|
||||
public interface IGalaxyBackend
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-pushed events the backend raises asynchronously (data-change, alarm,
|
||||
/// host-status). The frame handler subscribes once on connect and forwards each
|
||||
/// event to the Proxy as a typed <see cref="MessageKind"/> notification.
|
||||
/// </summary>
|
||||
event System.EventHandler<OnDataChangeNotification>? OnDataChange;
|
||||
event System.EventHandler<GalaxyAlarmEvent>? OnAlarmEvent;
|
||||
event System.EventHandler<HostConnectivityStatus>? OnHostStatusChanged;
|
||||
|
||||
Task<OpenSessionResponse> OpenSessionAsync(OpenSessionRequest req, CancellationToken ct);
|
||||
Task CloseSessionAsync(CloseSessionRequest req, CancellationToken ct);
|
||||
|
||||
@@ -29,6 +38,9 @@ public interface IGalaxyBackend
|
||||
Task AcknowledgeAlarmAsync(AlarmAckRequest req, CancellationToken ct);
|
||||
|
||||
Task<HistoryReadResponse> HistoryReadAsync(HistoryReadRequest req, CancellationToken ct);
|
||||
Task<HistoryReadProcessedResponse> HistoryReadProcessedAsync(HistoryReadProcessedRequest req, CancellationToken ct);
|
||||
Task<HistoryReadAtTimeResponse> HistoryReadAtTimeAsync(HistoryReadAtTimeRequest req, CancellationToken ct);
|
||||
Task<HistoryReadEventsResponse> HistoryReadEventsAsync(HistoryReadEventsRequest req, CancellationToken ct);
|
||||
|
||||
Task<RecycleStatusResponse> RecycleAsync(RecycleHostRequest req, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ArchestrA.MxAccess;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
@@ -17,9 +19,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
/// </summary>
|
||||
public sealed class MxAccessClient : IDisposable
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<MxAccessClient>();
|
||||
|
||||
private readonly StaPump _pump;
|
||||
private readonly IMxProxy _proxy;
|
||||
private readonly string _clientName;
|
||||
private readonly MxAccessClientOptions _options;
|
||||
|
||||
// Galaxy attribute reference → MXAccess item handle (set on first Subscribe/Read).
|
||||
private readonly ConcurrentDictionary<string, int> _addressToHandle = new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -30,39 +35,195 @@ public sealed class MxAccessClient : IDisposable
|
||||
|
||||
private int _connectionHandle;
|
||||
private bool _connected;
|
||||
private DateTime _lastObservedActivityUtc = DateTime.UtcNow;
|
||||
private CancellationTokenSource? _monitorCts;
|
||||
private int _reconnectCount;
|
||||
private bool _disposed;
|
||||
|
||||
public MxAccessClient(StaPump pump, IMxProxy proxy, string clientName)
|
||||
/// <summary>Fires whenever the connection transitions Connected ↔ Disconnected.</summary>
|
||||
public event EventHandler<bool>? ConnectionStateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Fires once per failed subscription replay after a reconnect. Carries the tag reference
|
||||
/// and the exception so the backend can propagate the degradation signal (e.g. mark the
|
||||
/// subscription bad on the Proxy side rather than silently losing its callback). Added for
|
||||
/// PR 6 low finding #2 — the replay loop previously ate per-tag failures silently and an
|
||||
/// operator would only find out that a specific subscription stopped updating through a
|
||||
/// data-quality complaint from downstream.
|
||||
/// </summary>
|
||||
public event EventHandler<SubscriptionReplayFailedEventArgs>? SubscriptionReplayFailed;
|
||||
|
||||
public MxAccessClient(StaPump pump, IMxProxy proxy, string clientName, MxAccessClientOptions? options = null)
|
||||
{
|
||||
_pump = pump;
|
||||
_proxy = proxy;
|
||||
_clientName = clientName;
|
||||
_options = options ?? new MxAccessClientOptions();
|
||||
_proxy.OnDataChange += OnDataChange;
|
||||
_proxy.OnWriteComplete += OnWriteComplete;
|
||||
}
|
||||
|
||||
public bool IsConnected => _connected;
|
||||
public int SubscriptionCount => _subscriptions.Count;
|
||||
public int ReconnectCount => _reconnectCount;
|
||||
|
||||
/// <summary>Connects on the STA thread. Idempotent.</summary>
|
||||
public Task<int> ConnectAsync() => _pump.InvokeAsync(() =>
|
||||
{
|
||||
if (_connected) return _connectionHandle;
|
||||
_connectionHandle = _proxy.Register(_clientName);
|
||||
_connected = true;
|
||||
return _connectionHandle;
|
||||
});
|
||||
/// <summary>
|
||||
/// Wonderware client identity used when registering with the LMXProxyServer. Surfaced so
|
||||
/// <see cref="Backend.MxAccessGalaxyBackend"/> can tag its <c>OnHostStatusChanged</c> IPC
|
||||
/// pushes with a stable gateway name per PR 8.
|
||||
/// </summary>
|
||||
public string ClientName => _clientName;
|
||||
|
||||
public Task DisconnectAsync() => _pump.InvokeAsync(() =>
|
||||
/// <summary>Connects on the STA thread. Idempotent. Starts the reconnect monitor on first call.</summary>
|
||||
public async Task<int> ConnectAsync()
|
||||
{
|
||||
if (!_connected) return;
|
||||
try { _proxy.Unregister(_connectionHandle); }
|
||||
finally
|
||||
var handle = await _pump.InvokeAsync(() =>
|
||||
{
|
||||
_connected = false;
|
||||
_addressToHandle.Clear();
|
||||
_handleToAddress.Clear();
|
||||
if (_connected) return _connectionHandle;
|
||||
_connectionHandle = _proxy.Register(_clientName);
|
||||
_connected = true;
|
||||
return _connectionHandle;
|
||||
});
|
||||
|
||||
ConnectionStateChanged?.Invoke(this, true);
|
||||
|
||||
if (_options.AutoReconnect && _monitorCts is null)
|
||||
{
|
||||
_monitorCts = new CancellationTokenSource();
|
||||
_ = Task.Run(() => MonitorLoopAsync(_monitorCts.Token));
|
||||
}
|
||||
});
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync()
|
||||
{
|
||||
_monitorCts?.Cancel();
|
||||
_monitorCts = null;
|
||||
|
||||
await _pump.InvokeAsync(() =>
|
||||
{
|
||||
if (!_connected) return;
|
||||
try { _proxy.Unregister(_connectionHandle); }
|
||||
finally
|
||||
{
|
||||
_connected = false;
|
||||
_addressToHandle.Clear();
|
||||
_handleToAddress.Clear();
|
||||
}
|
||||
});
|
||||
|
||||
ConnectionStateChanged?.Invoke(this, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Background loop that watches for connection liveness signals and triggers
|
||||
/// reconnect-with-replay when the connection appears dead. Per Phase 2 high finding #2:
|
||||
/// v1's MxAccessClient.Monitor pattern lifted into the new pump-based client. Uses
|
||||
/// observed-activity timestamp + optional probe-tag subscription. Without an explicit
|
||||
/// probe tag, falls back to "no data change in N seconds + no successful read in N
|
||||
/// seconds = unhealthy" — same shape as v1.
|
||||
/// </summary>
|
||||
private async Task MonitorLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { await Task.Delay(_options.MonitorInterval, ct); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
|
||||
if (!_connected || _disposed) continue;
|
||||
|
||||
var idle = DateTime.UtcNow - _lastObservedActivityUtc;
|
||||
if (idle <= _options.StaleThreshold) continue;
|
||||
|
||||
// Probe: try a no-op COM call. If the proxy is dead, the call will throw — that's
|
||||
// our reconnect signal. PR 6 low finding #1: AddItem allocates an MXAccess item
|
||||
// handle; we must RemoveItem it on the same pump turn or the long-running monitor
|
||||
// leaks one handle per probe cycle (one every MonitorInterval seconds, indefinitely).
|
||||
bool probeOk;
|
||||
try
|
||||
{
|
||||
probeOk = await _pump.InvokeAsync(() =>
|
||||
{
|
||||
int probeHandle = 0;
|
||||
try
|
||||
{
|
||||
probeHandle = _proxy.AddItem(_connectionHandle, "$Heartbeat");
|
||||
return probeHandle > 0;
|
||||
}
|
||||
catch { return false; }
|
||||
finally
|
||||
{
|
||||
if (probeHandle > 0)
|
||||
{
|
||||
try { _proxy.RemoveItem(_connectionHandle, probeHandle); }
|
||||
catch { /* proxy is dying; best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
catch { probeOk = false; }
|
||||
|
||||
if (probeOk)
|
||||
{
|
||||
_lastObservedActivityUtc = DateTime.UtcNow;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Connection appears dead — reconnect-with-replay.
|
||||
try
|
||||
{
|
||||
await _pump.InvokeAsync(() =>
|
||||
{
|
||||
try { _proxy.Unregister(_connectionHandle); } catch { /* dead anyway */ }
|
||||
_connected = false;
|
||||
});
|
||||
ConnectionStateChanged?.Invoke(this, false);
|
||||
|
||||
await _pump.InvokeAsync(() =>
|
||||
{
|
||||
_connectionHandle = _proxy.Register(_clientName);
|
||||
_connected = true;
|
||||
});
|
||||
_reconnectCount++;
|
||||
ConnectionStateChanged?.Invoke(this, true);
|
||||
|
||||
// Replay every subscription that was active before the disconnect. PR 6 low
|
||||
// finding #2: surface per-tag failures — log them and raise
|
||||
// SubscriptionReplayFailed so the backend can propagate the degraded state
|
||||
// (previously swallowed silently; downstream quality dropped without a signal).
|
||||
var snapshot = _addressToHandle.Keys.ToArray();
|
||||
_addressToHandle.Clear();
|
||||
_handleToAddress.Clear();
|
||||
var failed = 0;
|
||||
foreach (var fullRef in snapshot)
|
||||
{
|
||||
try { await SubscribeOnPumpAsync(fullRef); }
|
||||
catch (Exception subEx)
|
||||
{
|
||||
failed++;
|
||||
Log.Warning(subEx,
|
||||
"MXAccess subscription replay failed for {TagReference} after reconnect #{Reconnect}",
|
||||
fullRef, _reconnectCount);
|
||||
SubscriptionReplayFailed?.Invoke(this,
|
||||
new SubscriptionReplayFailedEventArgs(fullRef, subEx));
|
||||
}
|
||||
}
|
||||
|
||||
if (failed > 0)
|
||||
Log.Warning("Subscription replay completed — {Failed} of {Total} failed", failed, snapshot.Length);
|
||||
else
|
||||
Log.Information("Subscription replay completed — {Total} re-subscribed cleanly", snapshot.Length);
|
||||
|
||||
_lastObservedActivityUtc = DateTime.UtcNow;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Reconnect failed; back off and retry on the next tick.
|
||||
_connected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One-shot read implemented as a transient subscribe + unsubscribe.
|
||||
@@ -79,26 +240,72 @@ public sealed class MxAccessClient : IDisposable
|
||||
|
||||
// Stash the one-shot handler before sending the subscribe, then remove it after firing.
|
||||
_subscriptions.AddOrUpdate(fullReference, oneShot, (_, existing) => Combine(existing, oneShot));
|
||||
var addedToReadOnlyAttribute = !_addressToHandle.ContainsKey(fullReference);
|
||||
|
||||
var itemHandle = await SubscribeOnPumpAsync(fullReference);
|
||||
try
|
||||
{
|
||||
await SubscribeOnPumpAsync(fullReference);
|
||||
|
||||
using var _ = ct.Register(() => tcs.TrySetCanceled());
|
||||
var raceTask = await Task.WhenAny(tcs.Task, Task.Delay(timeout, ct));
|
||||
if (raceTask != tcs.Task) throw new TimeoutException($"MXAccess read of {fullReference} timed out after {timeout}");
|
||||
using var _ = ct.Register(() => tcs.TrySetCanceled());
|
||||
var raceTask = await Task.WhenAny(tcs.Task, Task.Delay(timeout, ct));
|
||||
if (raceTask != tcs.Task) throw new TimeoutException($"MXAccess read of {fullReference} timed out after {timeout}");
|
||||
|
||||
// Detach the one-shot handler.
|
||||
_subscriptions.AddOrUpdate(fullReference, _ => default!, (_, existing) => Remove(existing, oneShot));
|
||||
|
||||
return await tcs.Task;
|
||||
return await tcs.Task;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// High 1 — always detach the one-shot handler, even on cancellation/timeout/throw.
|
||||
// If we were the one who added the underlying MXAccess subscription (no other
|
||||
// caller had it), tear it down too so we don't leak a probe item handle.
|
||||
_subscriptions.AddOrUpdate(fullReference, _ => default!, (_, existing) => Remove(existing, oneShot));
|
||||
if (addedToReadOnlyAttribute)
|
||||
{
|
||||
try { await UnsubscribeAsync(fullReference); }
|
||||
catch { /* shutdown-best-effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Task WriteAsync(string fullReference, object value, int securityClassification = 0) =>
|
||||
_pump.InvokeAsync(() =>
|
||||
/// <summary>
|
||||
/// Writes <paramref name="value"/> to the runtime and AWAITS the OnWriteComplete
|
||||
/// callback so the caller learns the actual write status. Per Phase 2 medium finding #4
|
||||
/// in <c>exit-gate-phase-2.md</c>: the previous fire-and-forget version returned a
|
||||
/// false-positive Good even when the runtime rejected the write post-callback.
|
||||
/// </summary>
|
||||
public async Task<bool> WriteAsync(string fullReference, object value,
|
||||
int securityClassification = 0, TimeSpan? timeout = null)
|
||||
{
|
||||
if (!_connected) throw new InvalidOperationException("MxAccessClient not connected");
|
||||
var actualTimeout = timeout ?? TimeSpan.FromSeconds(5);
|
||||
|
||||
var itemHandle = await _pump.InvokeAsync(() => ResolveItem(fullReference));
|
||||
|
||||
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
if (!_pendingWrites.TryAdd(itemHandle, tcs))
|
||||
{
|
||||
if (!_connected) throw new InvalidOperationException("MxAccessClient not connected");
|
||||
var itemHandle = ResolveItem(fullReference);
|
||||
_proxy.Write(_connectionHandle, itemHandle, value, securityClassification);
|
||||
});
|
||||
// A prior write to the same item handle is still pending — uncommon but possible
|
||||
// if the caller spammed writes. Replace it: the older TCS observes a Cancelled task.
|
||||
if (_pendingWrites.TryRemove(itemHandle, out var prior))
|
||||
prior.TrySetCanceled();
|
||||
_pendingWrites[itemHandle] = tcs;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _pump.InvokeAsync(() =>
|
||||
_proxy.Write(_connectionHandle, itemHandle, value, securityClassification));
|
||||
|
||||
var raceTask = await Task.WhenAny(tcs.Task, Task.Delay(actualTimeout));
|
||||
if (raceTask != tcs.Task)
|
||||
throw new TimeoutException($"MXAccess write of {fullReference} timed out after {actualTimeout}");
|
||||
|
||||
return await tcs.Task;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pendingWrites.TryRemove(itemHandle, out _);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SubscribeAsync(string fullReference, Action<string, Vtq> callback)
|
||||
{
|
||||
@@ -148,6 +355,9 @@ public sealed class MxAccessClient : IDisposable
|
||||
{
|
||||
if (!_handleToAddress.TryGetValue(phItemHandle, out var fullRef)) return;
|
||||
|
||||
// Liveness: any data-change event is proof the connection is alive.
|
||||
_lastObservedActivityUtc = DateTime.UtcNow;
|
||||
|
||||
var ts = pftItemTimeStamp is DateTime dt ? dt.ToUniversalTime() : DateTime.UtcNow;
|
||||
var quality = (byte)Math.Min(255, Math.Max(0, pwItemQuality));
|
||||
var vtq = new Vtq(pvItemValue, ts, quality);
|
||||
@@ -169,10 +379,30 @@ public sealed class MxAccessClient : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
_monitorCts?.Cancel();
|
||||
|
||||
try { DisconnectAsync().GetAwaiter().GetResult(); }
|
||||
catch { /* swallow */ }
|
||||
|
||||
_proxy.OnDataChange -= OnDataChange;
|
||||
_proxy.OnWriteComplete -= OnWriteComplete;
|
||||
_monitorCts?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tunables for <see cref="MxAccessClient"/>'s reconnect monitor. Defaults match the v1
|
||||
/// monitor's polling cadence so behavior is consistent across the lift.
|
||||
/// </summary>
|
||||
public sealed class MxAccessClientOptions
|
||||
{
|
||||
/// <summary>Whether to start the background monitor at connect time.</summary>
|
||||
public bool AutoReconnect { get; init; } = true;
|
||||
|
||||
/// <summary>How often the monitor wakes up to check liveness.</summary>
|
||||
public TimeSpan MonitorInterval { get; init; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>If no data-change activity in this window, the monitor probes the connection.</summary>
|
||||
public TimeSpan StaleThreshold { get; init; } = TimeSpan.FromSeconds(60);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Fired by <see cref="MxAccessClient.SubscriptionReplayFailed"/> when a previously-active
|
||||
/// subscription fails to be restored after a reconnect. The backend should treat the tag as
|
||||
/// unhealthy until the next successful resubscribe.
|
||||
/// </summary>
|
||||
public sealed class SubscriptionReplayFailedEventArgs : EventArgs
|
||||
{
|
||||
public SubscriptionReplayFailedEventArgs(string tagReference, Exception exception)
|
||||
{
|
||||
TagReference = tagReference;
|
||||
Exception = exception;
|
||||
}
|
||||
|
||||
public string TagReference { get; }
|
||||
public Exception Exception { get; }
|
||||
}
|
||||
@@ -4,8 +4,11 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Alarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Stability;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||
@@ -18,22 +21,114 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||
/// MxAccess <c>AlarmExtension</c> primitives but the wire-up is also Phase 2 follow-up
|
||||
/// (the v1 alarm subsystem is its own subtree).
|
||||
/// </summary>
|
||||
public sealed class MxAccessGalaxyBackend : IGalaxyBackend
|
||||
public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
|
||||
{
|
||||
private readonly GalaxyRepository _repository;
|
||||
private readonly MxAccessClient _mx;
|
||||
private readonly IHistorianDataSource? _historian;
|
||||
private long _nextSessionId;
|
||||
private long _nextSubscriptionId;
|
||||
|
||||
// Active SubscriptionId → MXAccess full reference list — so Unsubscribe can find them.
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, IReadOnlyList<string>> _subs = new();
|
||||
// Reverse lookup: tag reference → subscription IDs subscribed to it (one tag may belong to many).
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, System.Collections.Concurrent.ConcurrentBag<long>>
|
||||
_refToSubs = new(System.StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public MxAccessGalaxyBackend(GalaxyRepository repository, MxAccessClient mx)
|
||||
public event System.EventHandler<OnDataChangeNotification>? OnDataChange;
|
||||
public event System.EventHandler<GalaxyAlarmEvent>? OnAlarmEvent;
|
||||
public event System.EventHandler<HostConnectivityStatus>? OnHostStatusChanged;
|
||||
|
||||
private readonly System.EventHandler<bool> _onConnectionStateChanged;
|
||||
private readonly GalaxyRuntimeProbeManager _probeManager;
|
||||
private readonly System.EventHandler<HostStateTransition> _onProbeStateChanged;
|
||||
private readonly GalaxyAlarmTracker _alarmTracker;
|
||||
private readonly System.EventHandler<AlarmTransition> _onAlarmTransition;
|
||||
|
||||
// Cached during DiscoverAsync so SubscribeAlarmsAsync knows which attributes to advise.
|
||||
// One entry per IsAlarm=true attribute in the last discovered hierarchy.
|
||||
private readonly System.Collections.Concurrent.ConcurrentBag<string> _discoveredAlarmTags = new();
|
||||
|
||||
public MxAccessGalaxyBackend(GalaxyRepository repository, MxAccessClient mx, IHistorianDataSource? historian = null)
|
||||
{
|
||||
_repository = repository;
|
||||
_mx = mx;
|
||||
_historian = historian;
|
||||
|
||||
// PR 8: gateway-level host-status push. When the MXAccess COM proxy transitions
|
||||
// connected↔disconnected, raise OnHostStatusChanged with a synthetic host entry named
|
||||
// after the Wonderware client identity so the Admin UI surfaces top-level transport
|
||||
// health even before per-platform/per-engine probing lands (deferred to a later PR that
|
||||
// ports v1's GalaxyRuntimeProbeManager with ScanState subscriptions).
|
||||
_onConnectionStateChanged = (_, connected) =>
|
||||
{
|
||||
OnHostStatusChanged?.Invoke(this, new HostConnectivityStatus
|
||||
{
|
||||
HostName = _mx.ClientName,
|
||||
RuntimeStatus = connected ? "Running" : "Stopped",
|
||||
LastObservedUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
});
|
||||
};
|
||||
_mx.ConnectionStateChanged += _onConnectionStateChanged;
|
||||
|
||||
// PR 13: per-platform runtime probes. ScanState subscriptions fire OnProbeCallback,
|
||||
// which runs the state machine and raises StateChanged on transitions we care about.
|
||||
// We forward each transition through the same OnHostStatusChanged IPC event that the
|
||||
// gateway-level ConnectionStateChanged uses — tagged with the platform's TagName so the
|
||||
// Admin UI can show per-host health independently from the top-level transport status.
|
||||
_probeManager = new GalaxyRuntimeProbeManager(
|
||||
subscribe: (probe, cb) => _mx.SubscribeAsync(probe, cb),
|
||||
unsubscribe: probe => _mx.UnsubscribeAsync(probe));
|
||||
_onProbeStateChanged = (_, t) =>
|
||||
{
|
||||
OnHostStatusChanged?.Invoke(this, new HostConnectivityStatus
|
||||
{
|
||||
HostName = t.TagName,
|
||||
RuntimeStatus = t.NewState switch
|
||||
{
|
||||
HostRuntimeState.Running => "Running",
|
||||
HostRuntimeState.Stopped => "Stopped",
|
||||
_ => "Unknown",
|
||||
},
|
||||
LastObservedUtcUnixMs = new DateTimeOffset(t.AtUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
});
|
||||
};
|
||||
_probeManager.StateChanged += _onProbeStateChanged;
|
||||
|
||||
// PR 14: alarm subsystem. Per IsAlarm=true attribute discovered, subscribe to the four
|
||||
// alarm-state attributes (.InAlarm/.Priority/.DescAttrName/.Acked), track lifecycle,
|
||||
// and raise GalaxyAlarmEvent on transitions — forwarded through the existing
|
||||
// OnAlarmEvent IPC event that the PR 4 ConnectionSink already wires into AlarmEvent frames.
|
||||
_alarmTracker = new GalaxyAlarmTracker(
|
||||
subscribe: (tag, cb) => _mx.SubscribeAsync(tag, cb),
|
||||
unsubscribe: tag => _mx.UnsubscribeAsync(tag),
|
||||
write: (tag, v) => _mx.WriteAsync(tag, v));
|
||||
_onAlarmTransition = (_, t) => OnAlarmEvent?.Invoke(this, new GalaxyAlarmEvent
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString("N"),
|
||||
ObjectTagName = t.AlarmTag,
|
||||
AlarmName = t.AlarmTag,
|
||||
Severity = t.Priority,
|
||||
StateTransition = t.Transition switch
|
||||
{
|
||||
AlarmStateTransition.Active => "Active",
|
||||
AlarmStateTransition.Acknowledged => "Acknowledged",
|
||||
AlarmStateTransition.Inactive => "Inactive",
|
||||
_ => "Unknown",
|
||||
},
|
||||
Message = t.DescAttrName ?? t.AlarmTag,
|
||||
UtcUnixMs = new DateTimeOffset(t.AtUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
});
|
||||
_alarmTracker.TransitionRaised += _onAlarmTransition;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exposed for tests. Production flow: DiscoverAsync completes → backend calls
|
||||
/// <c>SyncProbesAsync</c> with the runtime hosts (WinPlatform + AppEngine gobjects) to
|
||||
/// advise ScanState per host.
|
||||
/// </summary>
|
||||
internal GalaxyRuntimeProbeManager ProbeManager => _probeManager;
|
||||
|
||||
public async Task<OpenSessionResponse> OpenSessionAsync(OpenSessionRequest req, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -73,6 +168,34 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend
|
||||
Attributes = attrsByGobject.TryGetValue(o.GobjectId, out var a) ? a : Array.Empty<GalaxyAttributeInfo>(),
|
||||
}).ToArray();
|
||||
|
||||
// PR 14: cache alarm-bearing attribute full refs so SubscribeAlarmsAsync can advise
|
||||
// them on demand. Format matches the Galaxy reference grammar <tag>.<attr>.
|
||||
var freshAlarmTags = attributes
|
||||
.Where(a => a.IsAlarm)
|
||||
.Select(a => nameByGobject.TryGetValue(a.GobjectId, out var tn)
|
||||
? tn + "." + a.AttributeName
|
||||
: null)
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Cast<string>()
|
||||
.ToArray();
|
||||
while (_discoveredAlarmTags.TryTake(out _)) { }
|
||||
foreach (var t in freshAlarmTags) _discoveredAlarmTags.Add(t);
|
||||
|
||||
// PR 13: Sync the per-platform probe manager against the just-discovered hierarchy
|
||||
// so ScanState subscriptions track the current runtime set. Best-effort — probe
|
||||
// failures don't block Discover from returning, since the gateway-level signal from
|
||||
// MxAccessClient.ConnectionStateChanged still flows and the Admin UI degrades to
|
||||
// that level if any per-host probe couldn't advise.
|
||||
try
|
||||
{
|
||||
var targets = hierarchy
|
||||
.Where(o => o.CategoryId == GalaxyRuntimeProbeManager.CategoryWinPlatform
|
||||
|| o.CategoryId == GalaxyRuntimeProbeManager.CategoryAppEngine)
|
||||
.Select(o => new HostProbeTarget(o.TagName, o.CategoryId));
|
||||
await _probeManager.SyncAsync(targets).ConfigureAwait(false);
|
||||
}
|
||||
catch { /* swallow — Discover succeeded; probes are a diagnostic enrichment */ }
|
||||
|
||||
return new DiscoverHierarchyResponse { Success = true, Objects = objects };
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -120,8 +243,13 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend
|
||||
? null
|
||||
: MessagePackSerializer.Deserialize<object>(w.ValueBytes);
|
||||
|
||||
await _mx.WriteAsync(w.TagReference, value!);
|
||||
results.Add(new WriteValueResult { TagReference = w.TagReference, StatusCode = 0 });
|
||||
var ok = await _mx.WriteAsync(w.TagReference, value!);
|
||||
results.Add(new WriteValueResult
|
||||
{
|
||||
TagReference = w.TagReference,
|
||||
StatusCode = ok ? 0u : 0x80020000u, // Good or Bad_InternalError
|
||||
Error = ok ? null : "MXAccess runtime reported write failure",
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -137,12 +265,16 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend
|
||||
|
||||
try
|
||||
{
|
||||
// For each requested tag, register a subscription that publishes back via the
|
||||
// shared MXAccess data-change handler. The OnDataChange push frame to the Proxy
|
||||
// is wired in the upcoming subscription-push pass; for now the value is captured
|
||||
// for the first ReadAsync to hit it (so the subscribe surface itself is functional).
|
||||
foreach (var tag in req.TagReferences)
|
||||
await _mx.SubscribeAsync(tag, (_, __) => { /* push-frame plumbing in next iteration */ });
|
||||
{
|
||||
_refToSubs.AddOrUpdate(tag,
|
||||
_ => new System.Collections.Concurrent.ConcurrentBag<long> { sid },
|
||||
(_, bag) => { bag.Add(sid); return bag; });
|
||||
|
||||
// The MXAccess SubscribeAsync only takes one callback per tag; the same callback
|
||||
// fires for every active subscription of that tag — we fan out by SubscriptionId.
|
||||
await _mx.SubscribeAsync(tag, OnTagValueChanged);
|
||||
}
|
||||
|
||||
_subs[sid] = req.TagReferences;
|
||||
return new SubscribeResponse { Success = true, SubscriptionId = sid, ActualIntervalMs = req.RequestedIntervalMs };
|
||||
@@ -157,23 +289,255 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend
|
||||
{
|
||||
if (!_subs.TryRemove(req.SubscriptionId, out var refs)) return;
|
||||
foreach (var r in refs)
|
||||
await _mx.UnsubscribeAsync(r);
|
||||
{
|
||||
// Drop this subscription from the reverse map; only unsubscribe from MXAccess if no
|
||||
// other subscription is still listening (multiple Proxy subs may share a tag).
|
||||
_refToSubs.TryGetValue(r, out var bag);
|
||||
if (bag is not null)
|
||||
{
|
||||
var remaining = new System.Collections.Concurrent.ConcurrentBag<long>(
|
||||
bag.Where(id => id != req.SubscriptionId));
|
||||
if (remaining.IsEmpty)
|
||||
{
|
||||
_refToSubs.TryRemove(r, out _);
|
||||
await _mx.UnsubscribeAsync(r);
|
||||
}
|
||||
else
|
||||
{
|
||||
_refToSubs[r] = remaining;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Task SubscribeAlarmsAsync(AlarmSubscribeRequest req, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task AcknowledgeAlarmAsync(AlarmAckRequest req, CancellationToken ct) => Task.CompletedTask;
|
||||
/// <summary>
|
||||
/// Fires for every value change on any subscribed Galaxy attribute. Wraps the value in
|
||||
/// a <see cref="GalaxyDataValue"/> and raises <see cref="OnDataChange"/> once per
|
||||
/// subscription that includes this tag — the IPC sink translates that into outbound
|
||||
/// <c>OnDataChangeNotification</c> frames.
|
||||
/// </summary>
|
||||
private void OnTagValueChanged(string fullReference, MxAccess.Vtq vtq)
|
||||
{
|
||||
if (!_refToSubs.TryGetValue(fullReference, out var bag) || bag.IsEmpty) return;
|
||||
|
||||
public Task<HistoryReadResponse> HistoryReadAsync(HistoryReadRequest req, CancellationToken ct)
|
||||
=> Task.FromResult(new HistoryReadResponse
|
||||
var wireValue = ToWire(fullReference, vtq);
|
||||
// Emit one notification per active SubscriptionId for this tag — the Proxy fans out to
|
||||
// each ISubscribable consumer based on the SubscriptionId in the payload.
|
||||
foreach (var sid in bag.Distinct())
|
||||
{
|
||||
Success = false,
|
||||
Error = "Wonderware Historian plugin loader not yet wired (Phase 2 Task B.1.h follow-up)",
|
||||
Tags = Array.Empty<HistoryTagValues>(),
|
||||
});
|
||||
OnDataChange?.Invoke(this, new OnDataChangeNotification
|
||||
{
|
||||
SubscriptionId = sid,
|
||||
Values = new[] { wireValue },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR 14: advise every alarm-bearing attribute's 4-attr quartet. Best-effort per-alarm —
|
||||
/// a subscribe failure on one alarm doesn't abort the whole call, since operators prefer
|
||||
/// partial alarm coverage to none. Idempotent on repeat calls (tracker internally
|
||||
/// skips already-tracked alarms).
|
||||
/// </summary>
|
||||
public async Task SubscribeAlarmsAsync(AlarmSubscribeRequest req, CancellationToken ct)
|
||||
{
|
||||
foreach (var tag in _discoveredAlarmTags)
|
||||
{
|
||||
try { await _alarmTracker.TrackAsync(tag).ConfigureAwait(false); }
|
||||
catch { /* swallow per-alarm — tracker rolls back its own state on failure */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR 14: route operator ack through the tracker's AckMsg write path. EventId on the
|
||||
/// incoming request maps directly to the alarm full reference (Proxy-side naming
|
||||
/// convention from GalaxyProxyDriver.RaiseAlarmEvent → ev.EventId).
|
||||
/// </summary>
|
||||
public async Task AcknowledgeAlarmAsync(AlarmAckRequest req, CancellationToken ct)
|
||||
{
|
||||
// EventId carries a per-transition Guid.ToString("N"); there's no reverse map from
|
||||
// event id to alarm tag yet, so v1's convention (ack targets the condition) is matched
|
||||
// by reading the alarm name from the Comment envelope: v1 packed "<tag>|<comment>".
|
||||
// Until the Proxy is updated to send the alarm tag separately, fall back to treating
|
||||
// the EventId as the alarm tag — Client CLI passes it through unchanged.
|
||||
var tag = req.EventId;
|
||||
if (!string.IsNullOrWhiteSpace(tag))
|
||||
{
|
||||
try { await _alarmTracker.AcknowledgeAsync(tag, req.Comment ?? string.Empty).ConfigureAwait(false); }
|
||||
catch { /* swallow — ack failures surface via MxAccessClient.WriteAsync logs */ }
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<HistoryReadResponse> HistoryReadAsync(HistoryReadRequest req, CancellationToken ct)
|
||||
{
|
||||
if (_historian is null)
|
||||
return new HistoryReadResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "Historian disabled — no OTOPCUA_HISTORIAN_ENABLED configuration",
|
||||
Tags = Array.Empty<HistoryTagValues>(),
|
||||
};
|
||||
|
||||
var start = DateTimeOffset.FromUnixTimeMilliseconds(req.StartUtcUnixMs).UtcDateTime;
|
||||
var end = DateTimeOffset.FromUnixTimeMilliseconds(req.EndUtcUnixMs).UtcDateTime;
|
||||
var tags = new List<HistoryTagValues>(req.TagReferences.Length);
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var reference in req.TagReferences)
|
||||
{
|
||||
var samples = await _historian.ReadRawAsync(reference, start, end, (int)req.MaxValuesPerTag, ct).ConfigureAwait(false);
|
||||
tags.Add(new HistoryTagValues
|
||||
{
|
||||
TagReference = reference,
|
||||
Values = samples.Select(s => ToWire(reference, s)).ToArray(),
|
||||
});
|
||||
}
|
||||
return new HistoryReadResponse { Success = true, Tags = tags.ToArray() };
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new HistoryReadResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Historian read failed: {ex.Message}",
|
||||
Tags = tags.ToArray(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<HistoryReadProcessedResponse> HistoryReadProcessedAsync(
|
||||
HistoryReadProcessedRequest req, CancellationToken ct)
|
||||
{
|
||||
if (_historian is null)
|
||||
return new HistoryReadProcessedResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "Historian disabled — no OTOPCUA_HISTORIAN_ENABLED configuration",
|
||||
Values = Array.Empty<GalaxyDataValue>(),
|
||||
};
|
||||
|
||||
if (req.IntervalMs <= 0)
|
||||
return new HistoryReadProcessedResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "HistoryReadProcessed requires IntervalMs > 0",
|
||||
Values = Array.Empty<GalaxyDataValue>(),
|
||||
};
|
||||
|
||||
var start = DateTimeOffset.FromUnixTimeMilliseconds(req.StartUtcUnixMs).UtcDateTime;
|
||||
var end = DateTimeOffset.FromUnixTimeMilliseconds(req.EndUtcUnixMs).UtcDateTime;
|
||||
|
||||
try
|
||||
{
|
||||
var samples = await _historian.ReadAggregateAsync(
|
||||
req.TagReference, start, end, req.IntervalMs, req.AggregateColumn, ct).ConfigureAwait(false);
|
||||
|
||||
var wire = samples.Select(s => ToWire(req.TagReference, s)).ToArray();
|
||||
return new HistoryReadProcessedResponse { Success = true, Values = wire };
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new HistoryReadProcessedResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Historian aggregate read failed: {ex.Message}",
|
||||
Values = Array.Empty<GalaxyDataValue>(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<HistoryReadAtTimeResponse> HistoryReadAtTimeAsync(
|
||||
HistoryReadAtTimeRequest req, CancellationToken ct)
|
||||
{
|
||||
if (_historian is null)
|
||||
return new HistoryReadAtTimeResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "Historian disabled — no OTOPCUA_HISTORIAN_ENABLED configuration",
|
||||
Values = Array.Empty<GalaxyDataValue>(),
|
||||
};
|
||||
|
||||
if (req.TimestampsUtcUnixMs.Length == 0)
|
||||
return new HistoryReadAtTimeResponse { Success = true, Values = Array.Empty<GalaxyDataValue>() };
|
||||
|
||||
var timestamps = req.TimestampsUtcUnixMs
|
||||
.Select(ms => DateTimeOffset.FromUnixTimeMilliseconds(ms).UtcDateTime)
|
||||
.ToArray();
|
||||
|
||||
try
|
||||
{
|
||||
var samples = await _historian.ReadAtTimeAsync(req.TagReference, timestamps, ct).ConfigureAwait(false);
|
||||
var wire = samples.Select(s => ToWire(req.TagReference, s)).ToArray();
|
||||
return new HistoryReadAtTimeResponse { Success = true, Values = wire };
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new HistoryReadAtTimeResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Historian at-time read failed: {ex.Message}",
|
||||
Values = Array.Empty<GalaxyDataValue>(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<HistoryReadEventsResponse> HistoryReadEventsAsync(
|
||||
HistoryReadEventsRequest req, CancellationToken ct)
|
||||
{
|
||||
if (_historian is null)
|
||||
return new HistoryReadEventsResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "Historian disabled — no OTOPCUA_HISTORIAN_ENABLED configuration",
|
||||
Events = Array.Empty<GalaxyHistoricalEvent>(),
|
||||
};
|
||||
|
||||
var start = DateTimeOffset.FromUnixTimeMilliseconds(req.StartUtcUnixMs).UtcDateTime;
|
||||
var end = DateTimeOffset.FromUnixTimeMilliseconds(req.EndUtcUnixMs).UtcDateTime;
|
||||
|
||||
try
|
||||
{
|
||||
var events = await _historian.ReadEventsAsync(req.SourceName, start, end, req.MaxEvents, ct).ConfigureAwait(false);
|
||||
var wire = events.Select(e => new GalaxyHistoricalEvent
|
||||
{
|
||||
EventId = e.Id.ToString(),
|
||||
SourceName = e.Source,
|
||||
EventTimeUtcUnixMs = new DateTimeOffset(DateTime.SpecifyKind(e.EventTime, DateTimeKind.Utc), TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
ReceivedTimeUtcUnixMs = new DateTimeOffset(DateTime.SpecifyKind(e.ReceivedTime, DateTimeKind.Utc), TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
DisplayText = e.DisplayText,
|
||||
Severity = e.Severity,
|
||||
}).ToArray();
|
||||
return new HistoryReadEventsResponse { Success = true, Events = wire };
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new HistoryReadEventsResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Historian event read failed: {ex.Message}",
|
||||
Events = Array.Empty<GalaxyHistoricalEvent>(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public Task<RecycleStatusResponse> RecycleAsync(RecycleHostRequest req, CancellationToken ct)
|
||||
=> Task.FromResult(new RecycleStatusResponse { Accepted = true, GraceSeconds = 15 });
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_alarmTracker.TransitionRaised -= _onAlarmTransition;
|
||||
_alarmTracker.Dispose();
|
||||
_probeManager.StateChanged -= _onProbeStateChanged;
|
||||
_probeManager.Dispose();
|
||||
_mx.ConnectionStateChanged -= _onConnectionStateChanged;
|
||||
_historian?.Dispose();
|
||||
}
|
||||
|
||||
private static GalaxyDataValue ToWire(string reference, Vtq vtq) => new()
|
||||
{
|
||||
TagReference = reference,
|
||||
@@ -184,6 +548,39 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend
|
||||
ServerTimestampUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps a <see cref="HistorianSample"/> (raw historian row, OPC-UA-free) to the IPC wire
|
||||
/// shape. The Proxy decodes the MessagePack value and maps <see cref="HistorianSample.Quality"/>
|
||||
/// through <c>QualityMapper</c> on its side of the pipe — we keep the raw byte here so
|
||||
/// rich OPC DA status codes (e.g. <c>BadNotConnected</c>, <c>UncertainSubNormal</c>) survive
|
||||
/// the hop intact.
|
||||
/// </summary>
|
||||
private static GalaxyDataValue ToWire(string reference, HistorianSample sample) => new()
|
||||
{
|
||||
TagReference = reference,
|
||||
ValueBytes = sample.Value is null ? null : MessagePackSerializer.Serialize(sample.Value),
|
||||
ValueMessagePackType = 0,
|
||||
StatusCode = HistorianQualityMapper.Map(sample.Quality),
|
||||
SourceTimestampUtcUnixMs = new DateTimeOffset(sample.TimestampUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
ServerTimestampUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
};
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Maps a <see cref="HistorianAggregateSample"/> (one aggregate bucket) to the IPC wire
|
||||
/// shape. A null <see cref="HistorianAggregateSample.Value"/> means the aggregate was
|
||||
/// unavailable for the bucket — the Proxy translates that to OPC UA <c>BadNoData</c>.
|
||||
/// </summary>
|
||||
private static GalaxyDataValue ToWire(string reference, HistorianAggregateSample sample) => new()
|
||||
{
|
||||
TagReference = reference,
|
||||
ValueBytes = sample.Value is null ? null : MessagePackSerializer.Serialize(sample.Value.Value),
|
||||
ValueMessagePackType = 0,
|
||||
StatusCode = sample.Value is null ? 0x800E0000u /* BadNoData */ : 0x00000000u,
|
||||
SourceTimestampUtcUnixMs = new DateTimeOffset(sample.TimestampUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
ServerTimestampUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
};
|
||||
|
||||
private static GalaxyAttributeInfo MapAttribute(GalaxyAttributeRow row) => new()
|
||||
{
|
||||
AttributeName = row.AttributeName,
|
||||
@@ -192,6 +589,7 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend
|
||||
ArrayDim = row.ArrayDimension is int d and > 0 ? (uint)d : null,
|
||||
SecurityClassification = row.SecurityClassification,
|
||||
IsHistorized = row.IsHistorized,
|
||||
IsAlarm = row.IsAlarm,
|
||||
};
|
||||
|
||||
private static string MapCategory(int categoryId) => categoryId switch
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Stability;
|
||||
|
||||
/// <summary>
|
||||
/// Per-platform + per-AppEngine runtime probe. Subscribes to <c><TagName>.ScanState</c>
|
||||
/// for each $WinPlatform and $AppEngine gobject, tracks Unknown → Running → Stopped
|
||||
/// transitions, and fires <see cref="StateChanged"/> so <see cref="Backend.MxAccessGalaxyBackend"/>
|
||||
/// can forward per-host events through the existing IPC <c>OnHostStatusChanged</c> event.
|
||||
/// Pure-logic state machine with an injected clock so it's deterministically testable —
|
||||
/// port of v1 <c>GalaxyRuntimeProbeManager</c> without the OPC UA node-manager coupling.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// State machine rules (documented in v1's <c>runtimestatus.md</c> and preserved here):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>ScanState</c> is on-change-only — a stably-Running host may go hours without a
|
||||
/// callback. Running → Stopped is driven by an explicit <c>ScanState=false</c> callback,
|
||||
/// never by starvation.</item>
|
||||
/// <item>Unknown → Running is a startup transition and does NOT fire StateChanged (would
|
||||
/// paint every host as "just recovered" at startup, which is noise).</item>
|
||||
/// <item>Stopped → Running and Running → Stopped fire StateChanged. Unknown → Stopped
|
||||
/// fires StateChanged because that's a first-known-bad signal operators need.</item>
|
||||
/// <item>All public methods are thread-safe. Callbacks fire outside the internal lock to
|
||||
/// avoid lock inversion with caller-owned state.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public sealed class GalaxyRuntimeProbeManager : IDisposable
|
||||
{
|
||||
public const int CategoryWinPlatform = 1;
|
||||
public const int CategoryAppEngine = 3;
|
||||
public const string ProbeAttribute = ".ScanState";
|
||||
|
||||
private readonly Func<DateTime> _clock;
|
||||
private readonly Func<string, Action<string, Vtq>, Task> _subscribe;
|
||||
private readonly Func<string, Task> _unsubscribe;
|
||||
private readonly object _lock = new();
|
||||
|
||||
// probe tag → per-host state
|
||||
private readonly Dictionary<string, HostProbeState> _byProbe = new(StringComparer.OrdinalIgnoreCase);
|
||||
// tag name → probe tag (for reverse lookup on the desired-set diff)
|
||||
private readonly Dictionary<string, string> _probeByTagName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Fires on every state transition that operators should react to. See class remarks
|
||||
/// for the rules on which transitions fire.
|
||||
/// </summary>
|
||||
public event EventHandler<HostStateTransition>? StateChanged;
|
||||
|
||||
public GalaxyRuntimeProbeManager(
|
||||
Func<string, Action<string, Vtq>, Task> subscribe,
|
||||
Func<string, Task> unsubscribe)
|
||||
: this(subscribe, unsubscribe, () => DateTime.UtcNow) { }
|
||||
|
||||
internal GalaxyRuntimeProbeManager(
|
||||
Func<string, Action<string, Vtq>, Task> subscribe,
|
||||
Func<string, Task> unsubscribe,
|
||||
Func<DateTime> clock)
|
||||
{
|
||||
_subscribe = subscribe ?? throw new ArgumentNullException(nameof(subscribe));
|
||||
_unsubscribe = unsubscribe ?? throw new ArgumentNullException(nameof(unsubscribe));
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
}
|
||||
|
||||
/// <summary>Number of probes currently advised. Test/dashboard hook.</summary>
|
||||
public int ActiveProbeCount
|
||||
{
|
||||
get { lock (_lock) return _byProbe.Count; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot every currently-tracked host's state. One entry per probe.
|
||||
/// </summary>
|
||||
public IReadOnlyList<HostProbeSnapshot> SnapshotStates()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _byProbe.Select(kv => new HostProbeSnapshot(
|
||||
TagName: kv.Value.TagName,
|
||||
State: kv.Value.State,
|
||||
LastChangedUtc: kv.Value.LastStateChangeUtc)).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query the current runtime state for <paramref name="tagName"/>. Returns
|
||||
/// <see cref="HostRuntimeState.Unknown"/> when the host is not tracked.
|
||||
/// </summary>
|
||||
public HostRuntimeState GetState(string tagName)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_probeByTagName.TryGetValue(tagName, out var probe)
|
||||
&& _byProbe.TryGetValue(probe, out var state))
|
||||
return state.State;
|
||||
return HostRuntimeState.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diff the desired host set (filtered $WinPlatform / $AppEngine from the latest Discover)
|
||||
/// against the currently-tracked set and advise / unadvise as needed. Idempotent:
|
||||
/// calling twice with the same set does nothing.
|
||||
/// </summary>
|
||||
public async Task SyncAsync(IEnumerable<HostProbeTarget> desiredHosts)
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
var desired = desiredHosts
|
||||
.Where(h => !string.IsNullOrWhiteSpace(h.TagName))
|
||||
.ToDictionary(h => h.TagName, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
List<string> toAdvise;
|
||||
List<string> toUnadvise;
|
||||
lock (_lock)
|
||||
{
|
||||
toAdvise = desired.Keys
|
||||
.Where(tag => !_probeByTagName.ContainsKey(tag))
|
||||
.ToList();
|
||||
toUnadvise = _probeByTagName.Keys
|
||||
.Where(tag => !desired.ContainsKey(tag))
|
||||
.Select(tag => _probeByTagName[tag])
|
||||
.ToList();
|
||||
|
||||
foreach (var tag in toAdvise)
|
||||
{
|
||||
var probe = tag + ProbeAttribute;
|
||||
_probeByTagName[tag] = probe;
|
||||
_byProbe[probe] = new HostProbeState
|
||||
{
|
||||
TagName = tag,
|
||||
State = HostRuntimeState.Unknown,
|
||||
LastStateChangeUtc = _clock(),
|
||||
};
|
||||
}
|
||||
|
||||
foreach (var probe in toUnadvise)
|
||||
{
|
||||
_byProbe.Remove(probe);
|
||||
}
|
||||
|
||||
foreach (var removedTag in _probeByTagName.Keys.Where(t => !desired.ContainsKey(t)).ToList())
|
||||
{
|
||||
_probeByTagName.Remove(removedTag);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var tag in toAdvise)
|
||||
{
|
||||
var probe = tag + ProbeAttribute;
|
||||
try
|
||||
{
|
||||
await _subscribe(probe, OnProbeCallback);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Rollback on subscribe failure so a later Tick can't transition a never-advised
|
||||
// probe into a false Stopped state. Callers can re-Sync later to retry.
|
||||
lock (_lock)
|
||||
{
|
||||
_byProbe.Remove(probe);
|
||||
_probeByTagName.Remove(tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var probe in toUnadvise)
|
||||
{
|
||||
try { await _unsubscribe(probe); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public entry point for tests and internal callbacks. Production flow: MxAccessClient's
|
||||
/// SubscribeAsync delivers VTQ updates through the callback wired in <see cref="SyncAsync"/>,
|
||||
/// which calls this method under the lock to update state and fires
|
||||
/// <see cref="StateChanged"/> outside the lock for any transition that matters.
|
||||
/// </summary>
|
||||
public void OnProbeCallback(string probeTag, Vtq vtq)
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
HostStateTransition? transition = null;
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_byProbe.TryGetValue(probeTag, out var state)) return;
|
||||
|
||||
var isRunning = vtq.Quality >= 192 && vtq.Value is bool b && b;
|
||||
var now = _clock();
|
||||
var previous = state.State;
|
||||
state.LastCallbackUtc = now;
|
||||
|
||||
if (isRunning)
|
||||
{
|
||||
state.GoodUpdateCount++;
|
||||
if (previous != HostRuntimeState.Running)
|
||||
{
|
||||
state.State = HostRuntimeState.Running;
|
||||
state.LastStateChangeUtc = now;
|
||||
if (previous == HostRuntimeState.Stopped)
|
||||
{
|
||||
transition = new HostStateTransition(state.TagName, previous, HostRuntimeState.Running, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
state.FailureCount++;
|
||||
if (previous != HostRuntimeState.Stopped)
|
||||
{
|
||||
state.State = HostRuntimeState.Stopped;
|
||||
state.LastStateChangeUtc = now;
|
||||
transition = new HostStateTransition(state.TagName, previous, HostRuntimeState.Stopped, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (transition is { } t)
|
||||
{
|
||||
StateChanged?.Invoke(this, t);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
lock (_lock)
|
||||
{
|
||||
_byProbe.Clear();
|
||||
_probeByTagName.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class HostProbeState
|
||||
{
|
||||
public string TagName { get; set; } = "";
|
||||
public HostRuntimeState State { get; set; }
|
||||
public DateTime LastStateChangeUtc { get; set; }
|
||||
public DateTime? LastCallbackUtc { get; set; }
|
||||
public long GoodUpdateCount { get; set; }
|
||||
public long FailureCount { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
public enum HostRuntimeState
|
||||
{
|
||||
Unknown,
|
||||
Running,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
public sealed record HostStateTransition(
|
||||
string TagName,
|
||||
HostRuntimeState OldState,
|
||||
HostRuntimeState NewState,
|
||||
DateTime AtUtc);
|
||||
|
||||
public sealed record HostProbeSnapshot(
|
||||
string TagName,
|
||||
HostRuntimeState State,
|
||||
DateTime LastChangedUtc);
|
||||
|
||||
public readonly record struct HostProbeTarget(string TagName, int CategoryId)
|
||||
{
|
||||
public bool IsRuntimeHost =>
|
||||
CategoryId == GalaxyRuntimeProbeManager.CategoryWinPlatform
|
||||
|| CategoryId == GalaxyRuntimeProbeManager.CategoryAppEngine;
|
||||
}
|
||||
@@ -15,6 +15,13 @@ public sealed class StubGalaxyBackend : IGalaxyBackend
|
||||
private long _nextSessionId;
|
||||
private long _nextSubscriptionId;
|
||||
|
||||
// Stub backend never raises events — implements the interface members for symmetry.
|
||||
#pragma warning disable CS0067
|
||||
public event System.EventHandler<OnDataChangeNotification>? OnDataChange;
|
||||
public event System.EventHandler<GalaxyAlarmEvent>? OnAlarmEvent;
|
||||
public event System.EventHandler<HostConnectivityStatus>? OnHostStatusChanged;
|
||||
#pragma warning restore CS0067
|
||||
|
||||
public Task<OpenSessionResponse> OpenSessionAsync(OpenSessionRequest req, CancellationToken ct)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _nextSessionId);
|
||||
@@ -78,6 +85,33 @@ public sealed class StubGalaxyBackend : IGalaxyBackend
|
||||
Tags = System.Array.Empty<HistoryTagValues>(),
|
||||
});
|
||||
|
||||
public Task<HistoryReadProcessedResponse> HistoryReadProcessedAsync(
|
||||
HistoryReadProcessedRequest req, CancellationToken ct)
|
||||
=> Task.FromResult(new HistoryReadProcessedResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "stub: MXAccess code lift pending (Phase 2 Task B.1)",
|
||||
Values = System.Array.Empty<GalaxyDataValue>(),
|
||||
});
|
||||
|
||||
public Task<HistoryReadAtTimeResponse> HistoryReadAtTimeAsync(
|
||||
HistoryReadAtTimeRequest req, CancellationToken ct)
|
||||
=> Task.FromResult(new HistoryReadAtTimeResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "stub: MXAccess code lift pending (Phase 2 Task B.1)",
|
||||
Values = System.Array.Empty<GalaxyDataValue>(),
|
||||
});
|
||||
|
||||
public Task<HistoryReadEventsResponse> HistoryReadEventsAsync(
|
||||
HistoryReadEventsRequest req, CancellationToken ct)
|
||||
=> Task.FromResult(new HistoryReadEventsResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "stub: MXAccess code lift pending (Phase 2 Task B.1)",
|
||||
Events = System.Array.Empty<GalaxyHistoricalEvent>(),
|
||||
});
|
||||
|
||||
public Task<RecycleStatusResponse> RecycleAsync(RecycleHostRequest req, CancellationToken ct)
|
||||
=> Task.FromResult(new RecycleStatusResponse
|
||||
{
|
||||
|
||||
@@ -80,6 +80,27 @@ public sealed class GalaxyFrameHandler(IGalaxyBackend backend, ILogger logger) :
|
||||
await writer.WriteAsync(MessageKind.HistoryReadResponse, resp, ct);
|
||||
return;
|
||||
}
|
||||
case MessageKind.HistoryReadProcessedRequest:
|
||||
{
|
||||
var resp = await backend.HistoryReadProcessedAsync(
|
||||
Deserialize<HistoryReadProcessedRequest>(body), ct);
|
||||
await writer.WriteAsync(MessageKind.HistoryReadProcessedResponse, resp, ct);
|
||||
return;
|
||||
}
|
||||
case MessageKind.HistoryReadAtTimeRequest:
|
||||
{
|
||||
var resp = await backend.HistoryReadAtTimeAsync(
|
||||
Deserialize<HistoryReadAtTimeRequest>(body), ct);
|
||||
await writer.WriteAsync(MessageKind.HistoryReadAtTimeResponse, resp, ct);
|
||||
return;
|
||||
}
|
||||
case MessageKind.HistoryReadEventsRequest:
|
||||
{
|
||||
var resp = await backend.HistoryReadEventsAsync(
|
||||
Deserialize<HistoryReadEventsRequest>(body), ct);
|
||||
await writer.WriteAsync(MessageKind.HistoryReadEventsResponse, resp, ct);
|
||||
return;
|
||||
}
|
||||
case MessageKind.RecycleHostRequest:
|
||||
{
|
||||
var resp = await backend.RecycleAsync(Deserialize<RecycleHostRequest>(body), ct);
|
||||
@@ -99,9 +120,64 @@ public sealed class GalaxyFrameHandler(IGalaxyBackend backend, ILogger logger) :
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes the backend's server-pushed events for the lifetime of the connection.
|
||||
/// The returned disposable unsubscribes when the connection closes — without it the
|
||||
/// backend's static event invocation list would accumulate dead writer references and
|
||||
/// leak memory + raise <see cref="ObjectDisposedException"/> on every push.
|
||||
/// </summary>
|
||||
public IDisposable AttachConnection(FrameWriter writer)
|
||||
{
|
||||
var sink = new ConnectionSink(backend, writer, logger);
|
||||
sink.Attach();
|
||||
return sink;
|
||||
}
|
||||
|
||||
private static T Deserialize<T>(byte[] body) => MessagePackSerializer.Deserialize<T>(body);
|
||||
|
||||
private static Task SendErrorAsync(FrameWriter writer, string code, string message, CancellationToken ct)
|
||||
=> writer.WriteAsync(MessageKind.ErrorResponse,
|
||||
new ErrorResponse { Code = code, Message = message }, ct);
|
||||
|
||||
private sealed class ConnectionSink : IDisposable
|
||||
{
|
||||
private readonly IGalaxyBackend _backend;
|
||||
private readonly FrameWriter _writer;
|
||||
private readonly ILogger _logger;
|
||||
private EventHandler<OnDataChangeNotification>? _onData;
|
||||
private EventHandler<GalaxyAlarmEvent>? _onAlarm;
|
||||
private EventHandler<HostConnectivityStatus>? _onHost;
|
||||
|
||||
public ConnectionSink(IGalaxyBackend backend, FrameWriter writer, ILogger logger)
|
||||
{
|
||||
_backend = backend; _writer = writer; _logger = logger;
|
||||
}
|
||||
|
||||
public void Attach()
|
||||
{
|
||||
_onData = (_, e) => Push(MessageKind.OnDataChangeNotification, e);
|
||||
_onAlarm = (_, e) => Push(MessageKind.AlarmEvent, e);
|
||||
_onHost = (_, e) => Push(MessageKind.RuntimeStatusChange,
|
||||
new RuntimeStatusChangeNotification { Status = e });
|
||||
_backend.OnDataChange += _onData;
|
||||
_backend.OnAlarmEvent += _onAlarm;
|
||||
_backend.OnHostStatusChanged += _onHost;
|
||||
}
|
||||
|
||||
private void Push<T>(MessageKind kind, T payload)
|
||||
{
|
||||
// Fire-and-forget — pushes can race with disposal of the writer. We swallow
|
||||
// ObjectDisposedException because the dispose path will detach this sink shortly.
|
||||
try { _writer.WriteAsync(kind, payload, CancellationToken.None).GetAwaiter().GetResult(); }
|
||||
catch (ObjectDisposedException) { }
|
||||
catch (Exception ex) { _logger.Warning(ex, "ConnectionSink push failed for {Kind}", kind); }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_onData is not null) _backend.OnDataChange -= _onData;
|
||||
if (_onAlarm is not null) _backend.OnAlarmEvent -= _onAlarm;
|
||||
if (_onHost is not null) _backend.OnHostStatusChanged -= _onHost;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,8 @@ public sealed class PipeServer : IDisposable
|
||||
new HelloAck { Accepted = true, HostName = Environment.MachineName },
|
||||
linked.Token).ConfigureAwait(false);
|
||||
|
||||
using var attachment = handler.AttachConnection(writer);
|
||||
|
||||
while (!linked.Token.IsCancellationRequested)
|
||||
{
|
||||
var frame = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false);
|
||||
@@ -157,4 +159,19 @@ public sealed class PipeServer : IDisposable
|
||||
public interface IFrameHandler
|
||||
{
|
||||
Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Called once per accepted connection after the Hello handshake. Lets the handler
|
||||
/// attach server-pushed event sinks (data-change, alarm, host-status) to the
|
||||
/// connection's <paramref name="writer"/>. Returns an <see cref="IDisposable"/> the
|
||||
/// pipe server disposes when the connection closes — backends use it to unsubscribe.
|
||||
/// Implementations that don't push events can return <see cref="NoopAttachment"/>.
|
||||
/// </summary>
|
||||
IDisposable AttachConnection(FrameWriter writer);
|
||||
|
||||
public sealed class NoopAttachment : IDisposable
|
||||
{
|
||||
public static readonly NoopAttachment Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
@@ -27,4 +28,6 @@ public sealed class StubFrameHandler : IFrameHandler
|
||||
new ErrorResponse { Code = "not-implemented", Message = $"Kind {kind} is stubbed — MXAccess lift deferred" },
|
||||
ct);
|
||||
}
|
||||
|
||||
public IDisposable AttachConnection(FrameWriter writer) => IFrameHandler.NoopAttachment.Instance;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Threading;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Ipc;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
|
||||
@@ -66,9 +67,11 @@ public static class Program
|
||||
pump = new StaPump("Galaxy.Sta");
|
||||
pump.WaitForStartedAsync().GetAwaiter().GetResult();
|
||||
mx = new MxAccessClient(pump, new MxProxyAdapter(), clientName);
|
||||
var historian = BuildHistorianIfEnabled();
|
||||
backend = new MxAccessGalaxyBackend(
|
||||
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = zbConn }),
|
||||
mx);
|
||||
mx,
|
||||
historian);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -77,6 +80,7 @@ public static class Program
|
||||
try { server.RunAsync(handler, cts.Token).GetAwaiter().GetResult(); }
|
||||
finally
|
||||
{
|
||||
(backend as IDisposable)?.Dispose();
|
||||
mx?.Dispose();
|
||||
pump?.Dispose();
|
||||
}
|
||||
@@ -91,4 +95,45 @@ public static class Program
|
||||
}
|
||||
finally { Log.CloseAndFlush(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="HistorianDataSource"/> from the OTOPCUA_HISTORIAN_* environment
|
||||
/// variables the supervisor passes at spawn time. Returns null when the historian is
|
||||
/// disabled (default) so <c>MxAccessGalaxyBackend.HistoryReadAsync</c> returns a clear
|
||||
/// "not configured" error instead of attempting an SDK connection to localhost.
|
||||
/// </summary>
|
||||
private static IHistorianDataSource? BuildHistorianIfEnabled()
|
||||
{
|
||||
var enabled = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_ENABLED");
|
||||
if (!string.Equals(enabled, "true", StringComparison.OrdinalIgnoreCase) && enabled != "1")
|
||||
return null;
|
||||
|
||||
var cfg = new HistorianConfiguration
|
||||
{
|
||||
Enabled = true,
|
||||
ServerName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVER") ?? "localhost",
|
||||
Port = TryParseInt("OTOPCUA_HISTORIAN_PORT", 32568),
|
||||
IntegratedSecurity = !string.Equals(Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_INTEGRATED"), "false", StringComparison.OrdinalIgnoreCase),
|
||||
UserName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_USER"),
|
||||
Password = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_PASS"),
|
||||
CommandTimeoutSeconds = TryParseInt("OTOPCUA_HISTORIAN_TIMEOUT_SEC", 30),
|
||||
MaxValuesPerRead = TryParseInt("OTOPCUA_HISTORIAN_MAX_VALUES", 10000),
|
||||
FailureCooldownSeconds = TryParseInt("OTOPCUA_HISTORIAN_COOLDOWN_SEC", 60),
|
||||
};
|
||||
|
||||
var servers = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVERS");
|
||||
if (!string.IsNullOrWhiteSpace(servers))
|
||||
cfg.ServerNames = new System.Collections.Generic.List<string>(
|
||||
servers.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries));
|
||||
|
||||
Log.Information("Historian enabled — {NodeCount} configured node(s), port={Port}",
|
||||
cfg.ServerNames.Count > 0 ? cfg.ServerNames.Count : 1, cfg.Port);
|
||||
return new HistorianDataSource(cfg);
|
||||
}
|
||||
|
||||
private static int TryParseInt(string envName, int defaultValue)
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable(envName);
|
||||
return int.TryParse(raw, out var parsed) ? parsed : defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,43 @@
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="ArchestrA.MxAccess">
|
||||
<HintPath>..\..\lib\ArchestrA.MxAccess.dll</HintPath>
|
||||
<Private>true</Private>
|
||||
</Reference>
|
||||
<!-- Wonderware Historian SDK — consumed by Backend/Historian/ for HistoryReadAsync.
|
||||
Previously lived in the v1 Historian.Aveva plugin; folded into Driver.Galaxy.Host
|
||||
for PR #5 because this host is already Galaxy-specific. -->
|
||||
<Reference Include="aahClientManaged">
|
||||
<HintPath>..\..\lib\aahClientManaged.dll</HintPath>
|
||||
<EmbedInteropTypes>false</EmbedInteropTypes>
|
||||
</Reference>
|
||||
<Reference Include="aahClientCommon">
|
||||
<HintPath>..\..\lib\aahClientCommon.dll</HintPath>
|
||||
<EmbedInteropTypes>false</EmbedInteropTypes>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Historian SDK native and satellite DLLs — staged beside the host exe so the
|
||||
aahClientManaged wrapper can P/Invoke into them without an AssemblyResolve hook. -->
|
||||
<None Include="..\..\lib\aahClient.dll">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Include="..\..\lib\Historian.CBE.dll">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Include="..\..\lib\Historian.DPAPI.dll">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Include="..\..\lib\ArchestrA.CloudHistorian.Contract.dll">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -123,7 +123,8 @@ public sealed class GalaxyProxyDriver(GalaxyProxyOptions options)
|
||||
IsArray: attr.IsArray,
|
||||
ArrayDim: attr.ArrayDim,
|
||||
SecurityClass: MapSecurity(attr.SecurityClassification),
|
||||
IsHistorized: attr.IsHistorized));
|
||||
IsHistorized: attr.IsHistorized,
|
||||
IsAlarm: attr.IsAlarm));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -296,10 +297,50 @@ public sealed class GalaxyProxyDriver(GalaxyProxyOptions options)
|
||||
return new HistoryReadResult(samples, ContinuationPoint: null);
|
||||
}
|
||||
|
||||
public Task<HistoryReadResult> ReadProcessedAsync(
|
||||
public async Task<HistoryReadResult> ReadProcessedAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
|
||||
HistoryAggregateType aggregate, CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException("Galaxy historian processed reads are not supported in v2; use ReadRawAsync.");
|
||||
{
|
||||
var client = RequireClient();
|
||||
var column = MapAggregateToColumn(aggregate);
|
||||
|
||||
var resp = await client.CallAsync<HistoryReadProcessedRequest, HistoryReadProcessedResponse>(
|
||||
MessageKind.HistoryReadProcessedRequest,
|
||||
new HistoryReadProcessedRequest
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TagReference = fullReference,
|
||||
StartUtcUnixMs = new DateTimeOffset(startUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
EndUtcUnixMs = new DateTimeOffset(endUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
IntervalMs = (long)interval.TotalMilliseconds,
|
||||
AggregateColumn = column,
|
||||
},
|
||||
MessageKind.HistoryReadProcessedResponse,
|
||||
cancellationToken);
|
||||
|
||||
if (!resp.Success)
|
||||
throw new InvalidOperationException($"Galaxy.Host HistoryReadProcessed failed: {resp.Error}");
|
||||
|
||||
IReadOnlyList<DataValueSnapshot> samples = [.. resp.Values.Select(ToSnapshot)];
|
||||
return new HistoryReadResult(samples, ContinuationPoint: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps the OPC UA Part 13 aggregate enum onto the Wonderware Historian
|
||||
/// AnalogSummaryQuery column names consumed by <c>HistorianDataSource.ReadAggregateAsync</c>.
|
||||
/// Kept on the Proxy side so Galaxy.Host stays OPC-UA-free.
|
||||
/// </summary>
|
||||
internal static string MapAggregateToColumn(HistoryAggregateType aggregate) => aggregate switch
|
||||
{
|
||||
HistoryAggregateType.Average => "Average",
|
||||
HistoryAggregateType.Minimum => "Minimum",
|
||||
HistoryAggregateType.Maximum => "Maximum",
|
||||
HistoryAggregateType.Count => "ValueCount",
|
||||
HistoryAggregateType.Total => throw new NotSupportedException(
|
||||
"HistoryAggregateType.Total is not supported by the Wonderware Historian AnalogSummary " +
|
||||
"query — use Average × Count on the caller side, or switch to Average/Minimum/Maximum/Count."),
|
||||
_ => throw new NotSupportedException($"Unknown HistoryAggregateType {aggregate}"),
|
||||
};
|
||||
|
||||
// ---- IRediscoverable ----
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
|
||||
@@ -30,6 +30,15 @@ public sealed class GalaxyAttributeInfo
|
||||
[Key(3)] public uint? ArrayDim { get; set; }
|
||||
[Key(4)] public int SecurityClassification { get; set; }
|
||||
[Key(5)] public bool IsHistorized { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the attribute has an AlarmExtension primitive in the Galaxy repository
|
||||
/// (<c>primitive_definition.primitive_name = 'AlarmExtension'</c>). The generic
|
||||
/// node-manager uses this to enrich the variable's OPC UA node with an
|
||||
/// <c>AlarmConditionState</c> during address-space build. Added in PR 9 as the
|
||||
/// discovery-side foundation for the alarm event wire-up that follows in PR 10+.
|
||||
/// </summary>
|
||||
[Key(6)] public bool IsAlarm { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
|
||||
@@ -48,8 +48,14 @@ public enum MessageKind : byte
|
||||
AlarmEvent = 0x51,
|
||||
AlarmAckRequest = 0x52,
|
||||
|
||||
HistoryReadRequest = 0x60,
|
||||
HistoryReadResponse = 0x61,
|
||||
HistoryReadRequest = 0x60,
|
||||
HistoryReadResponse = 0x61,
|
||||
HistoryReadProcessedRequest = 0x62,
|
||||
HistoryReadProcessedResponse = 0x63,
|
||||
HistoryReadAtTimeRequest = 0x64,
|
||||
HistoryReadAtTimeResponse = 0x65,
|
||||
HistoryReadEventsRequest = 0x66,
|
||||
HistoryReadEventsResponse = 0x67,
|
||||
|
||||
HostConnectivityStatus = 0x70,
|
||||
RuntimeStatusChange = 0x71,
|
||||
|
||||
@@ -26,3 +26,85 @@ public sealed class HistoryReadResponse
|
||||
[Key(1)] public string? Error { get; set; }
|
||||
[Key(2)] public HistoryTagValues[] Tags { get; set; } = System.Array.Empty<HistoryTagValues>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processed (aggregated) historian read — OPC UA HistoryReadProcessed service. The
|
||||
/// aggregate column is a string (e.g. "Average", "Minimum") mapped by the Proxy from the
|
||||
/// OPC UA HistoryAggregateType enum so Galaxy.Host stays OPC-UA-free.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class HistoryReadProcessedRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
[Key(1)] public string TagReference { get; set; } = string.Empty;
|
||||
[Key(2)] public long StartUtcUnixMs { get; set; }
|
||||
[Key(3)] public long EndUtcUnixMs { get; set; }
|
||||
[Key(4)] public long IntervalMs { get; set; }
|
||||
[Key(5)] public string AggregateColumn { get; set; } = "Average";
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class HistoryReadProcessedResponse
|
||||
{
|
||||
[Key(0)] public bool Success { get; set; }
|
||||
[Key(1)] public string? Error { get; set; }
|
||||
[Key(2)] public GalaxyDataValue[] Values { get; set; } = System.Array.Empty<GalaxyDataValue>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// At-time historian read — OPC UA HistoryReadAtTime service. Returns one sample per
|
||||
/// requested timestamp (interpolated when no exact match exists). The per-timestamp array
|
||||
/// is flow-encoded as Unix milliseconds to avoid MessagePack DateTime quirks.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class HistoryReadAtTimeRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
[Key(1)] public string TagReference { get; set; } = string.Empty;
|
||||
[Key(2)] public long[] TimestampsUtcUnixMs { get; set; } = System.Array.Empty<long>();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class HistoryReadAtTimeResponse
|
||||
{
|
||||
[Key(0)] public bool Success { get; set; }
|
||||
[Key(1)] public string? Error { get; set; }
|
||||
[Key(2)] public GalaxyDataValue[] Values { get; set; } = System.Array.Empty<GalaxyDataValue>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Historical events read — OPC UA HistoryReadEvents service and Alarm & Condition
|
||||
/// history. <c>SourceName</c> null means "all sources". Distinct from the live
|
||||
/// <see cref="GalaxyAlarmEvent"/> stream because historical rows carry both
|
||||
/// <c>EventTime</c> (when the event occurred in the process) and <c>ReceivedTime</c>
|
||||
/// (when the Historian persisted it) and have no StateTransition — the Historian logs
|
||||
/// the instantaneous event, not the OPC UA alarm lifecycle.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class HistoryReadEventsRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
[Key(1)] public string? SourceName { get; set; }
|
||||
[Key(2)] public long StartUtcUnixMs { get; set; }
|
||||
[Key(3)] public long EndUtcUnixMs { get; set; }
|
||||
[Key(4)] public int MaxEvents { get; set; } = 1000;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class GalaxyHistoricalEvent
|
||||
{
|
||||
[Key(0)] public string EventId { get; set; } = string.Empty;
|
||||
[Key(1)] public string? SourceName { get; set; }
|
||||
[Key(2)] public long EventTimeUtcUnixMs { get; set; }
|
||||
[Key(3)] public long ReceivedTimeUtcUnixMs { get; set; }
|
||||
[Key(4)] public string? DisplayText { get; set; }
|
||||
[Key(5)] public ushort Severity { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class HistoryReadEventsResponse
|
||||
{
|
||||
[Key(0)] public bool Success { get; set; }
|
||||
[Key(1)] public string? Error { get; set; }
|
||||
[Key(2)] public GalaxyHistoricalEvent[] Events { get; set; } = System.Array.Empty<GalaxyHistoricalEvent>();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||
<!-- Deploy next to Host.exe under bin/<cfg>/Historian/ so F5 works without a manual copy. -->
|
||||
<HistorianPluginOutputDir>$(MSBuildThisFileDirectory)..\ZB.MOM.WW.OtOpcUa.Host\bin\$(Configuration)\net48\Historian\</HistorianPluginOutputDir>
|
||||
<!--
|
||||
Phase 2 Stream D — V1 ARCHIVE. Plugs into the legacy in-process Host's
|
||||
Wonderware Historian loader. Will be ported into Driver.Galaxy.Host's
|
||||
Backend/Historian/ subtree when MxAccessGalaxyBackend.HistoryReadAsync is
|
||||
wired (Task B.1.h follow-up). See docs/v2/V1_ARCHIVE_STATUS.md.
|
||||
-->
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Host</RootNamespace>
|
||||
<AssemblyName>ZB.MOM.WW.OtOpcUa.Host</AssemblyName>
|
||||
<!--
|
||||
Phase 2 Stream D — V1 ARCHIVE. Functionally superseded by:
|
||||
src/ZB.MOM.WW.OtOpcUa.Server (host process, .NET 10)
|
||||
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host (out-of-process MXAccess, net48 x86)
|
||||
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy (in-process driver, .NET 10)
|
||||
Kept in the build graph because Historian.Aveva + IntegrationTests still
|
||||
transitively reference it. Deletion is the subject of Phase 2 PR 3 (separate from
|
||||
this PR 2). See docs/v2/V1_ARCHIVE_STATUS.md.
|
||||
-->
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E;
|
||||
|
||||
[Trait("Category", "ParityE2E")]
|
||||
[Collection(nameof(ParityCollection))]
|
||||
public sealed class HierarchyParityTests
|
||||
{
|
||||
private readonly ParityFixture _fx;
|
||||
public HierarchyParityTests(ParityFixture fx) => _fx = fx;
|
||||
|
||||
[Fact]
|
||||
public async Task Discover_returns_at_least_one_gobject_with_attributes()
|
||||
{
|
||||
_fx.SkipIfUnavailable();
|
||||
|
||||
var builder = new RecordingAddressSpaceBuilder();
|
||||
await _fx.Driver!.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.Count.ShouldBeGreaterThan(0,
|
||||
"live Galaxy ZB has at least one deployed gobject");
|
||||
builder.Variables.Count.ShouldBeGreaterThan(0,
|
||||
"at least one gobject in the dev Galaxy carries dynamic attributes");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Discover_emits_only_lowercase_browse_paths_for_each_attribute()
|
||||
{
|
||||
// OPC UA browse paths are case-sensitive; the v1 server emits Galaxy attribute
|
||||
// names verbatim (camelCase like "PV.Input.Value"). Parity invariant: every
|
||||
// emitted variable's full reference contains a '.' separating the gobject
|
||||
// tag-name from the attribute name (Galaxy reference grammar).
|
||||
_fx.SkipIfUnavailable();
|
||||
|
||||
var builder = new RecordingAddressSpaceBuilder();
|
||||
await _fx.Driver!.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.ShouldAllBe(v => v.AttributeInfo.FullName.Contains('.'),
|
||||
"Galaxy MXAccess full references are 'tag.attribute'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Discover_marks_at_least_one_attribute_as_historized_when_HistoryExtension_present()
|
||||
{
|
||||
_fx.SkipIfUnavailable();
|
||||
|
||||
var builder = new RecordingAddressSpaceBuilder();
|
||||
await _fx.Driver!.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
// Soft assertion — some Galaxies are configuration-only with no Historian extensions.
|
||||
// We only check the field flows through correctly when populated.
|
||||
var historized = builder.Variables.Count(v => v.AttributeInfo.IsHistorized);
|
||||
// Just assert the count is non-negative — the value itself is data-dependent.
|
||||
historized.ShouldBeGreaterThanOrEqualTo(0);
|
||||
}
|
||||
}
|
||||
136
tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ParityFixture.cs
Normal file
136
tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ParityFixture.cs
Normal file
@@ -0,0 +1,136 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Reflection;
|
||||
using System.Security.Principal;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// Spawns one <c>OtOpcUa.Driver.Galaxy.Host.exe</c> subprocess per test class and exposes
|
||||
/// a connected <see cref="GalaxyProxyDriver"/> for the tests. Per Phase 2 plan §"Stream E
|
||||
/// Parity Validation": the Proxy owns a session against a real out-of-process Host running
|
||||
/// the production-shape <c>MxAccessGalaxyBackend</c> backed by live ZB + MXAccess COM.
|
||||
/// Skipped when the Host EXE isn't built, when ZB SQL is unreachable, or when the dev box
|
||||
/// runs as Administrator (the IPC ACL explicitly denies Administrators per decision #76).
|
||||
/// </summary>
|
||||
public sealed class ParityFixture : IAsyncLifetime
|
||||
{
|
||||
public GalaxyProxyDriver? Driver { get; private set; }
|
||||
public string? SkipReason { get; private set; }
|
||||
|
||||
private Process? _host;
|
||||
private const string Secret = "parity-suite-secret";
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) { SkipReason = "Windows-only"; return; }
|
||||
if (IsAdministrator()) { SkipReason = "PipeAcl denies Administrators on dev shells"; return; }
|
||||
if (!await ZbReachableAsync()) { SkipReason = "Galaxy ZB SQL not reachable on localhost:1433"; return; }
|
||||
|
||||
var hostExe = FindHostExe();
|
||||
if (hostExe is null) { SkipReason = "Galaxy.Host EXE not built — run `dotnet build src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host`"; return; }
|
||||
|
||||
// Use the SQL-only DB backend by default — exercises the full IPC + dispatcher + SQL
|
||||
// path without requiring a healthy MXAccess connection. Tests that need MXAccess
|
||||
// override via env vars before InitializeAsync is called (use a separate fixture).
|
||||
var pipe = $"OtOpcUaGalaxyParity-{Guid.NewGuid():N}";
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
var sid = identity.User!;
|
||||
|
||||
var psi = new ProcessStartInfo(hostExe)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
EnvironmentVariables =
|
||||
{
|
||||
["OTOPCUA_GALAXY_PIPE"] = pipe,
|
||||
["OTOPCUA_ALLOWED_SID"] = sid.Value,
|
||||
["OTOPCUA_GALAXY_SECRET"] = Secret,
|
||||
["OTOPCUA_GALAXY_BACKEND"] = "db",
|
||||
["OTOPCUA_GALAXY_ZB_CONN"] = "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;",
|
||||
},
|
||||
};
|
||||
|
||||
_host = Process.Start(psi)
|
||||
?? throw new InvalidOperationException("Failed to spawn Galaxy.Host EXE");
|
||||
|
||||
// Give the PipeServer ~2s to bind. The supervisor's HeartbeatMonitor can do this
|
||||
// in production with retry, but the parity tests are best served by a fixed warm-up.
|
||||
await Task.Delay(2_000);
|
||||
|
||||
Driver = new GalaxyProxyDriver(new GalaxyProxyOptions
|
||||
{
|
||||
DriverInstanceId = "parity",
|
||||
PipeName = pipe,
|
||||
SharedSecret = Secret,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(5),
|
||||
});
|
||||
|
||||
await Driver.InitializeAsync(driverConfigJson: "{}", CancellationToken.None);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Driver is not null)
|
||||
{
|
||||
try { await Driver.ShutdownAsync(CancellationToken.None); } catch { /* shutdown */ }
|
||||
Driver.Dispose();
|
||||
}
|
||||
|
||||
if (_host is not null && !_host.HasExited)
|
||||
{
|
||||
try { _host.Kill(entireProcessTree: true); } catch { /* ignore */ }
|
||||
try { _host.WaitForExit(5_000); } catch { /* ignore */ }
|
||||
}
|
||||
_host?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Skip the test if the fixture couldn't initialize. xUnit Skip.If pattern.</summary>
|
||||
public void SkipIfUnavailable()
|
||||
{
|
||||
if (SkipReason is not null)
|
||||
Assert.Skip(SkipReason);
|
||||
}
|
||||
|
||||
private static bool IsAdministrator()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) return false;
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
|
||||
}
|
||||
|
||||
private static async Task<bool> ZbReachableAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
var task = client.ConnectAsync("localhost", 1433);
|
||||
return await Task.WhenAny(task, Task.Delay(1_500)) == task && client.Connected;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static string? FindHostExe()
|
||||
{
|
||||
var asmDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
|
||||
var solutionRoot = asmDir;
|
||||
for (var i = 0; i < 8 && solutionRoot is not null; i++)
|
||||
{
|
||||
if (File.Exists(Path.Combine(solutionRoot, "ZB.MOM.WW.OtOpcUa.slnx"))) break;
|
||||
solutionRoot = Path.GetDirectoryName(solutionRoot);
|
||||
}
|
||||
if (solutionRoot is null) return null;
|
||||
|
||||
var path = Path.Combine(solutionRoot,
|
||||
"src", "ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host", "bin", "Debug", "net48",
|
||||
"OtOpcUa.Driver.Galaxy.Host.exe");
|
||||
return File.Exists(path) ? path : null;
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition(nameof(ParityCollection))]
|
||||
public sealed class ParityCollection : ICollectionFixture<ParityFixture> { }
|
||||
@@ -0,0 +1,38 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// Test-only <see cref="IAddressSpaceBuilder"/> that records every Folder + Variable
|
||||
/// registration. Mirrors the v1 in-process address-space build so tests can assert on
|
||||
/// the same shape the legacy <c>LmxNodeManager</c> produced.
|
||||
/// </summary>
|
||||
public sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<RecordedFolder> Folders { get; } = new();
|
||||
public List<RecordedVariable> Variables { get; } = new();
|
||||
public List<RecordedProperty> Properties { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{
|
||||
Folders.Add(new RecordedFolder(browseName, displayName));
|
||||
return this; // single flat builder for tests; nesting irrelevant for parity assertions
|
||||
}
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
Variables.Add(new RecordedVariable(browseName, displayName, attributeInfo));
|
||||
return new RecordedVariableHandle(attributeInfo.FullName);
|
||||
}
|
||||
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value)
|
||||
{
|
||||
Properties.Add(new RecordedProperty(browseName, dataType, value));
|
||||
}
|
||||
|
||||
public sealed record RecordedFolder(string BrowseName, string DisplayName);
|
||||
public sealed record RecordedVariable(string BrowseName, string DisplayName, DriverAttributeInfo AttributeInfo);
|
||||
public sealed record RecordedProperty(string BrowseName, DriverDataType DataType, object? Value);
|
||||
|
||||
private sealed record RecordedVariableHandle(string FullReference) : IVariableHandle;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for the four 2026-04-13 stability findings (commits <c>c76ab8f</c>,
|
||||
/// <c>7310925</c>) per Phase 2 plan §"Stream E.3". Each test asserts the v2 topology
|
||||
/// does not reintroduce the v1 defect.
|
||||
/// </summary>
|
||||
[Trait("Category", "ParityE2E")]
|
||||
[Trait("Subcategory", "StabilityRegression")]
|
||||
[Collection(nameof(ParityCollection))]
|
||||
public sealed class StabilityFindingsRegressionTests
|
||||
{
|
||||
private readonly ParityFixture _fx;
|
||||
public StabilityFindingsRegressionTests(ParityFixture fx) => _fx = fx;
|
||||
|
||||
/// <summary>
|
||||
/// Finding #1 — <em>phantom probe subscription flips Tick() to Stopped</em>. When the
|
||||
/// v1 GalaxyRuntimeProbeManager failed to subscribe a probe, it left a phantom entry
|
||||
/// that the next Tick() flipped to Stopped, fanning Bad-quality across unrelated
|
||||
/// subtrees. v2 regression net: a failed subscribe must not affect host status of
|
||||
/// subscriptions that did succeed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Failed_subscribe_does_not_corrupt_unrelated_host_status()
|
||||
{
|
||||
_fx.SkipIfUnavailable();
|
||||
|
||||
// GetHostStatuses pre-subscribe — baseline.
|
||||
var preSubscribe = _fx.Driver!.GetHostStatuses().Count;
|
||||
|
||||
// Try to subscribe to a nonsense reference; the Host should reject it without
|
||||
// poisoning the host-status table.
|
||||
try
|
||||
{
|
||||
await _fx.Driver.SubscribeAsync(
|
||||
new[] { "nonexistent.tag.does.not.exist[]" },
|
||||
TimeSpan.FromSeconds(1),
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch { /* expected — bad reference */ }
|
||||
|
||||
var postSubscribe = _fx.Driver.GetHostStatuses().Count;
|
||||
postSubscribe.ShouldBe(preSubscribe,
|
||||
"failed subscribe must not mutate the host-status snapshot");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finding #2 — <em>cross-host quality clear wipes sibling state during recovery</em>.
|
||||
/// v1 cleared all subscriptions when ANY host changed state, even healthy peers.
|
||||
/// v2 regression net: host-status events must be scoped to the affected host name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Host_status_change_event_carries_specific_host_name_not_global_clear()
|
||||
{
|
||||
_fx.SkipIfUnavailable();
|
||||
|
||||
var changes = new List<HostStatusChangedEventArgs>();
|
||||
EventHandler<HostStatusChangedEventArgs> handler = (_, e) => changes.Add(e);
|
||||
_fx.Driver!.OnHostStatusChanged += handler;
|
||||
try
|
||||
{
|
||||
// We can't deterministically force a Host status transition in the suite without
|
||||
// tearing down the COM connection. The structural assertion is sufficient: the
|
||||
// event TYPE carries a specific HostName, OldState, NewState — there is no
|
||||
// "global clear" payload. v1's bug was structural; v2's event signature
|
||||
// mathematically prevents reintroduction.
|
||||
typeof(HostStatusChangedEventArgs).GetProperty("HostName")
|
||||
.ShouldNotBeNull("event signature must scope to a specific host");
|
||||
typeof(HostStatusChangedEventArgs).GetProperty("OldState")
|
||||
.ShouldNotBeNull();
|
||||
typeof(HostStatusChangedEventArgs).GetProperty("NewState")
|
||||
.ShouldNotBeNull();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_fx.Driver.OnHostStatusChanged -= handler;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finding #3 — <em>sync-over-async on the OPC UA stack thread</em>. v1 had spots
|
||||
/// that called <c>.Result</c> / <c>.Wait()</c> from the OPC UA stack callback,
|
||||
/// deadlocking under load. v2 regression net: every <see cref="GalaxyProxyDriver"/>
|
||||
/// capability method is async-all-the-way; a reflection scan asserts no
|
||||
/// <c>.GetAwaiter().GetResult()</c> appears in IL of the public surface.
|
||||
/// Implemented as a structural shape assertion — every public method returning
|
||||
/// <see cref="Task"/> or <see cref="Task{TResult}"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void All_GalaxyProxyDriver_capability_methods_return_Task_for_async_correctness()
|
||||
{
|
||||
_fx.SkipIfUnavailable();
|
||||
|
||||
var driverType = typeof(Proxy.GalaxyProxyDriver);
|
||||
var capabilityMethods = driverType.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
|
||||
.Where(m => m.DeclaringType == driverType
|
||||
&& !m.IsSpecialName
|
||||
&& m.Name is "InitializeAsync" or "ReinitializeAsync" or "ShutdownAsync"
|
||||
or "FlushOptionalCachesAsync" or "DiscoverAsync"
|
||||
or "ReadAsync" or "WriteAsync"
|
||||
or "SubscribeAsync" or "UnsubscribeAsync"
|
||||
or "SubscribeAlarmsAsync" or "UnsubscribeAlarmsAsync" or "AcknowledgeAsync"
|
||||
or "ReadRawAsync" or "ReadProcessedAsync");
|
||||
|
||||
foreach (var m in capabilityMethods)
|
||||
{
|
||||
(m.ReturnType == typeof(Task) || m.ReturnType.IsGenericType && m.ReturnType.GetGenericTypeDefinition() == typeof(Task<>))
|
||||
.ShouldBeTrue($"{m.Name} must return Task or Task<T> — sync-over-async risks deadlock under load");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finding #4 — <em>fire-and-forget alarm tasks racing shutdown</em>. v1 fired
|
||||
/// <c>Task.Run(() => raiseAlarm)</c> without awaiting, so shutdown could complete
|
||||
/// while the task was still touching disposed state. v2 regression net: alarm
|
||||
/// acknowledgement is sequential and awaited — verified by the integration test
|
||||
/// <c>AcknowledgeAsync</c> returning a completed Task that doesn't leave background
|
||||
/// work.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_completes_before_returning_no_background_tasks()
|
||||
{
|
||||
_fx.SkipIfUnavailable();
|
||||
|
||||
// We can't easily acknowledge a real Galaxy alarm in this fixture, but we can
|
||||
// assert the call shape: a synchronous-from-the-caller-perspective await without
|
||||
// throwing or leaving a pending continuation.
|
||||
await _fx.Driver!.AcknowledgeAsync(
|
||||
new[] { new AlarmAcknowledgeRequest("nonexistent-source", "nonexistent-event", "test ack") },
|
||||
CancellationToken.None);
|
||||
|
||||
// If we got here, the call awaited cleanly — no fire-and-forget background work
|
||||
// left running after the caller returned.
|
||||
true.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||
<!--
|
||||
We DO NOT reference Galaxy.Host (net48 x86) here. The Host runs as a subprocess —
|
||||
this project only needs to spawn the EXE and talk to it via named pipes through
|
||||
the Proxy. Cross-FX type loading is what bit the earlier in-process attempt.
|
||||
-->
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using MessagePack;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AlarmDiscoveryTests
|
||||
{
|
||||
/// <summary>
|
||||
/// PR 9 — IsAlarm must survive the MessagePack round-trip at Key=6 position.
|
||||
/// Regression guard: any reorder of keys in GalaxyAttributeInfo would silently corrupt
|
||||
/// the flag in the wire payload since MessagePack encodes by key number, not field name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GalaxyAttributeInfo_IsAlarm_round_trips_true_through_MessagePack()
|
||||
{
|
||||
var input = new GalaxyAttributeInfo
|
||||
{
|
||||
AttributeName = "TankLevel",
|
||||
MxDataType = 2,
|
||||
IsArray = false,
|
||||
ArrayDim = null,
|
||||
SecurityClassification = 1,
|
||||
IsHistorized = true,
|
||||
IsAlarm = true,
|
||||
};
|
||||
|
||||
var bytes = MessagePackSerializer.Serialize(input);
|
||||
var decoded = MessagePackSerializer.Deserialize<GalaxyAttributeInfo>(bytes);
|
||||
|
||||
decoded.IsAlarm.ShouldBeTrue();
|
||||
decoded.IsHistorized.ShouldBeTrue();
|
||||
decoded.AttributeName.ShouldBe("TankLevel");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GalaxyAttributeInfo_IsAlarm_round_trips_false_through_MessagePack()
|
||||
{
|
||||
var input = new GalaxyAttributeInfo { AttributeName = "ColorRgb", IsAlarm = false };
|
||||
var bytes = MessagePackSerializer.Serialize(input);
|
||||
var decoded = MessagePackSerializer.Deserialize<GalaxyAttributeInfo>(bytes);
|
||||
decoded.IsAlarm.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire-compat guard: payloads serialized before PR 9 (which omit Key=6) must still
|
||||
/// deserialize cleanly — MessagePack treats missing keys as default. This lets a newer
|
||||
/// Proxy talk to an older Host during a rolling upgrade without a crash.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Pre_PR9_payload_without_IsAlarm_key_deserializes_with_default_false()
|
||||
{
|
||||
// Build a 6-field payload (keys 0..5) matching the pre-PR9 shape by serializing a
|
||||
// stand-in class with the same key layout but no Key=6.
|
||||
var pre = new PrePR9Shape
|
||||
{
|
||||
AttributeName = "Legacy",
|
||||
MxDataType = 1,
|
||||
IsArray = false,
|
||||
ArrayDim = null,
|
||||
SecurityClassification = 0,
|
||||
IsHistorized = false,
|
||||
};
|
||||
var bytes = MessagePackSerializer.Serialize(pre);
|
||||
|
||||
var decoded = MessagePackSerializer.Deserialize<GalaxyAttributeInfo>(bytes);
|
||||
decoded.AttributeName.ShouldBe("Legacy");
|
||||
decoded.IsAlarm.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class PrePR9Shape
|
||||
{
|
||||
[Key(0)] public string AttributeName { get; set; } = string.Empty;
|
||||
[Key(1)] public int MxDataType { get; set; }
|
||||
[Key(2)] public bool IsArray { get; set; }
|
||||
[Key(3)] public uint? ArrayDim { get; set; }
|
||||
[Key(4)] public int SecurityClassification { get; set; }
|
||||
[Key(5)] public bool IsHistorized { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Alarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class GalaxyAlarmTrackerTests
|
||||
{
|
||||
private sealed class FakeSubscriber
|
||||
{
|
||||
public readonly ConcurrentDictionary<string, Action<string, Vtq>> Subs = new();
|
||||
public readonly ConcurrentQueue<string> Unsubs = new();
|
||||
public readonly ConcurrentQueue<(string Tag, object Value)> Writes = new();
|
||||
public bool WriteReturns { get; set; } = true;
|
||||
|
||||
public Task Subscribe(string tag, Action<string, Vtq> cb)
|
||||
{
|
||||
Subs[tag] = cb;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public Task Unsubscribe(string tag)
|
||||
{
|
||||
Unsubs.Enqueue(tag);
|
||||
Subs.TryRemove(tag, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public Task<bool> Write(string tag, object value)
|
||||
{
|
||||
Writes.Enqueue((tag, value));
|
||||
return Task.FromResult(WriteReturns);
|
||||
}
|
||||
}
|
||||
|
||||
private static Vtq Bool(bool v) => new(v, DateTime.UtcNow, 192);
|
||||
private static Vtq Int(int v) => new(v, DateTime.UtcNow, 192);
|
||||
private static Vtq Str(string v) => new(v, DateTime.UtcNow, 192);
|
||||
|
||||
[Fact]
|
||||
public async Task Track_subscribes_to_four_alarm_attributes()
|
||||
{
|
||||
var fake = new FakeSubscriber();
|
||||
using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write);
|
||||
|
||||
await t.TrackAsync("Tank.Level.HiHi");
|
||||
|
||||
fake.Subs.ShouldContainKey("Tank.Level.HiHi.InAlarm");
|
||||
fake.Subs.ShouldContainKey("Tank.Level.HiHi.Priority");
|
||||
fake.Subs.ShouldContainKey("Tank.Level.HiHi.DescAttrName");
|
||||
fake.Subs.ShouldContainKey("Tank.Level.HiHi.Acked");
|
||||
t.TrackedAlarmCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Track_is_idempotent_on_repeat_call()
|
||||
{
|
||||
var fake = new FakeSubscriber();
|
||||
using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write);
|
||||
|
||||
await t.TrackAsync("Alarm.A");
|
||||
await t.TrackAsync("Alarm.A");
|
||||
|
||||
t.TrackedAlarmCount.ShouldBe(1);
|
||||
fake.Subs.Count.ShouldBe(4); // 4 sub calls, not 8
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InAlarm_false_to_true_fires_Active_transition()
|
||||
{
|
||||
var fake = new FakeSubscriber();
|
||||
using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write);
|
||||
var transitions = new ConcurrentQueue<AlarmTransition>();
|
||||
t.TransitionRaised += (_, tr) => transitions.Enqueue(tr);
|
||||
|
||||
await t.TrackAsync("Alarm.A");
|
||||
fake.Subs["Alarm.A.Priority"]("Alarm.A.Priority", Int(500));
|
||||
fake.Subs["Alarm.A.DescAttrName"]("Alarm.A.DescAttrName", Str("TankLevelHiHi"));
|
||||
fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(true));
|
||||
|
||||
transitions.Count.ShouldBe(1);
|
||||
transitions.TryDequeue(out var tr).ShouldBeTrue();
|
||||
tr!.Transition.ShouldBe(AlarmStateTransition.Active);
|
||||
tr.Priority.ShouldBe(500);
|
||||
tr.DescAttrName.ShouldBe("TankLevelHiHi");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InAlarm_true_to_false_fires_Inactive_transition()
|
||||
{
|
||||
var fake = new FakeSubscriber();
|
||||
using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write);
|
||||
var transitions = new ConcurrentQueue<AlarmTransition>();
|
||||
t.TransitionRaised += (_, tr) => transitions.Enqueue(tr);
|
||||
|
||||
await t.TrackAsync("Alarm.A");
|
||||
fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(true));
|
||||
fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(false));
|
||||
|
||||
transitions.Count.ShouldBe(2);
|
||||
transitions.TryDequeue(out _);
|
||||
transitions.TryDequeue(out var tr).ShouldBeTrue();
|
||||
tr!.Transition.ShouldBe(AlarmStateTransition.Inactive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Acked_false_to_true_fires_Acknowledged_while_InAlarm_is_true()
|
||||
{
|
||||
var fake = new FakeSubscriber();
|
||||
using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write);
|
||||
var transitions = new ConcurrentQueue<AlarmTransition>();
|
||||
t.TransitionRaised += (_, tr) => transitions.Enqueue(tr);
|
||||
|
||||
await t.TrackAsync("Alarm.A");
|
||||
fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(true)); // Active, clears Acked flag
|
||||
fake.Subs["Alarm.A.Acked"]("Alarm.A.Acked", Bool(true)); // Acknowledged
|
||||
|
||||
transitions.Count.ShouldBe(2);
|
||||
transitions.TryDequeue(out _);
|
||||
transitions.TryDequeue(out var tr).ShouldBeTrue();
|
||||
tr!.Transition.ShouldBe(AlarmStateTransition.Acknowledged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Acked_transition_while_InAlarm_is_false_does_not_fire()
|
||||
{
|
||||
var fake = new FakeSubscriber();
|
||||
using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write);
|
||||
var transitions = new ConcurrentQueue<AlarmTransition>();
|
||||
t.TransitionRaised += (_, tr) => transitions.Enqueue(tr);
|
||||
|
||||
await t.TrackAsync("Alarm.A");
|
||||
// Initial Acked=true on subscribe (alarm is at rest, pre-ack'd) — should not fire.
|
||||
fake.Subs["Alarm.A.Acked"]("Alarm.A.Acked", Bool(true));
|
||||
|
||||
transitions.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Acknowledge_writes_AckMsg_with_comment()
|
||||
{
|
||||
var fake = new FakeSubscriber();
|
||||
using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write);
|
||||
await t.TrackAsync("Alarm.A");
|
||||
|
||||
var ok = await t.AcknowledgeAsync("Alarm.A", "acknowledged by operator");
|
||||
|
||||
ok.ShouldBeTrue();
|
||||
fake.Writes.Count.ShouldBe(1);
|
||||
fake.Writes.TryDequeue(out var w).ShouldBeTrue();
|
||||
w.Tag.ShouldBe("Alarm.A.AckMsg");
|
||||
w.Value.ShouldBe("acknowledged by operator");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_reports_latest_fields()
|
||||
{
|
||||
var fake = new FakeSubscriber();
|
||||
using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write);
|
||||
await t.TrackAsync("Alarm.A");
|
||||
fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(true));
|
||||
fake.Subs["Alarm.A.Priority"]("Alarm.A.Priority", Int(900));
|
||||
fake.Subs["Alarm.A.DescAttrName"]("Alarm.A.DescAttrName", Str("MyAlarm"));
|
||||
fake.Subs["Alarm.A.Acked"]("Alarm.A.Acked", Bool(true));
|
||||
|
||||
var snap = t.SnapshotStates();
|
||||
snap.Count.ShouldBe(1);
|
||||
snap[0].InAlarm.ShouldBeTrue();
|
||||
snap[0].Acked.ShouldBeTrue();
|
||||
snap[0].Priority.ShouldBe(900);
|
||||
snap[0].DescAttrName.ShouldBe("MyAlarm");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Foreign_probe_callback_is_dropped()
|
||||
{
|
||||
var fake = new FakeSubscriber();
|
||||
using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write);
|
||||
var transitions = new ConcurrentQueue<AlarmTransition>();
|
||||
t.TransitionRaised += (_, tr) => transitions.Enqueue(tr);
|
||||
|
||||
// No TrackAsync was called — this callback is foreign and should be silently ignored.
|
||||
t.OnProbeCallback("Unknown.InAlarm", Bool(true));
|
||||
|
||||
transitions.Count.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Stability;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class GalaxyRuntimeProbeManagerTests
|
||||
{
|
||||
private sealed class FakeSubscriber
|
||||
{
|
||||
public readonly ConcurrentDictionary<string, Action<string, Vtq>> Subs = new();
|
||||
public readonly ConcurrentQueue<string> UnsubCalls = new();
|
||||
public bool FailSubscribeFor { get; set; }
|
||||
public string? FailSubscribeTag { get; set; }
|
||||
|
||||
public Task Subscribe(string probe, Action<string, Vtq> cb)
|
||||
{
|
||||
if (FailSubscribeFor && string.Equals(probe, FailSubscribeTag, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException("subscribe refused");
|
||||
Subs[probe] = cb;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task Unsubscribe(string probe)
|
||||
{
|
||||
UnsubCalls.Enqueue(probe);
|
||||
Subs.TryRemove(probe, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private static Vtq Good(bool scanState) => new(scanState, DateTime.UtcNow, 192);
|
||||
private static Vtq Bad() => new(null, DateTime.UtcNow, 0);
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_subscribes_to_ScanState_per_host()
|
||||
{
|
||||
var subs = new FakeSubscriber();
|
||||
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe);
|
||||
|
||||
await mgr.SyncAsync(new[]
|
||||
{
|
||||
new HostProbeTarget("PlatformA", GalaxyRuntimeProbeManager.CategoryWinPlatform),
|
||||
new HostProbeTarget("EngineB", GalaxyRuntimeProbeManager.CategoryAppEngine),
|
||||
});
|
||||
|
||||
mgr.ActiveProbeCount.ShouldBe(2);
|
||||
subs.Subs.ShouldContainKey("PlatformA.ScanState");
|
||||
subs.Subs.ShouldContainKey("EngineB.ScanState");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_is_idempotent_on_repeat_call_with_same_set()
|
||||
{
|
||||
var subs = new FakeSubscriber();
|
||||
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe);
|
||||
var targets = new[] { new HostProbeTarget("PlatformA", 1) };
|
||||
|
||||
await mgr.SyncAsync(targets);
|
||||
await mgr.SyncAsync(targets);
|
||||
|
||||
mgr.ActiveProbeCount.ShouldBe(1);
|
||||
subs.Subs.Count.ShouldBe(1);
|
||||
subs.UnsubCalls.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_unadvises_removed_hosts()
|
||||
{
|
||||
var subs = new FakeSubscriber();
|
||||
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe);
|
||||
|
||||
await mgr.SyncAsync(new[]
|
||||
{
|
||||
new HostProbeTarget("PlatformA", 1),
|
||||
new HostProbeTarget("PlatformB", 1),
|
||||
});
|
||||
await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) });
|
||||
|
||||
mgr.ActiveProbeCount.ShouldBe(1);
|
||||
subs.UnsubCalls.ShouldContain("PlatformB.ScanState");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_failure_rolls_back_host_entry_so_later_transitions_do_not_fire_stale_events()
|
||||
{
|
||||
var subs = new FakeSubscriber { FailSubscribeFor = true, FailSubscribeTag = "PlatformA.ScanState" };
|
||||
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe);
|
||||
|
||||
await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) });
|
||||
|
||||
mgr.ActiveProbeCount.ShouldBe(0); // rolled back
|
||||
mgr.GetState("PlatformA").ShouldBe(HostRuntimeState.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_to_Running_does_not_fire_StateChanged()
|
||||
{
|
||||
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||
var subs = new FakeSubscriber();
|
||||
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now);
|
||||
var transitions = new ConcurrentQueue<HostStateTransition>();
|
||||
mgr.StateChanged += (_, t) => transitions.Enqueue(t);
|
||||
|
||||
await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) });
|
||||
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true));
|
||||
|
||||
mgr.GetState("PlatformA").ShouldBe(HostRuntimeState.Running);
|
||||
transitions.Count.ShouldBe(0); // startup transition, operators don't care
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Running_to_Stopped_fires_StateChanged_with_both_states()
|
||||
{
|
||||
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||
var subs = new FakeSubscriber();
|
||||
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now);
|
||||
var transitions = new ConcurrentQueue<HostStateTransition>();
|
||||
mgr.StateChanged += (_, t) => transitions.Enqueue(t);
|
||||
|
||||
await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) });
|
||||
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true)); // Unknown→Running (silent)
|
||||
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(false)); // Running→Stopped (fires)
|
||||
|
||||
transitions.Count.ShouldBe(1);
|
||||
transitions.TryDequeue(out var t).ShouldBeTrue();
|
||||
t!.TagName.ShouldBe("PlatformA");
|
||||
t.OldState.ShouldBe(HostRuntimeState.Running);
|
||||
t.NewState.ShouldBe(HostRuntimeState.Stopped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stopped_to_Running_fires_StateChanged_for_recovery()
|
||||
{
|
||||
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||
var subs = new FakeSubscriber();
|
||||
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now);
|
||||
var transitions = new ConcurrentQueue<HostStateTransition>();
|
||||
mgr.StateChanged += (_, t) => transitions.Enqueue(t);
|
||||
|
||||
await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) });
|
||||
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true)); // Unknown→Running (silent)
|
||||
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(false)); // Running→Stopped (fires)
|
||||
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true)); // Stopped→Running (fires)
|
||||
|
||||
transitions.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_to_Stopped_fires_StateChanged_for_first_known_bad_signal()
|
||||
{
|
||||
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||
var subs = new FakeSubscriber();
|
||||
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now);
|
||||
var transitions = new ConcurrentQueue<HostStateTransition>();
|
||||
mgr.StateChanged += (_, t) => transitions.Enqueue(t);
|
||||
|
||||
await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) });
|
||||
// First callback is bad-quality — we must flag the host Stopped so operators see it.
|
||||
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Bad());
|
||||
|
||||
transitions.Count.ShouldBe(1);
|
||||
transitions.TryDequeue(out var t).ShouldBeTrue();
|
||||
t!.OldState.ShouldBe(HostRuntimeState.Unknown);
|
||||
t.NewState.ShouldBe(HostRuntimeState.Stopped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeated_Good_Running_callbacks_do_not_fire_duplicate_events()
|
||||
{
|
||||
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||
var subs = new FakeSubscriber();
|
||||
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now);
|
||||
var count = 0;
|
||||
mgr.StateChanged += (_, _) => Interlocked.Increment(ref count);
|
||||
|
||||
await mgr.SyncAsync(new[] { new HostProbeTarget("PlatformA", 1) });
|
||||
for (var i = 0; i < 5; i++)
|
||||
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true));
|
||||
|
||||
count.ShouldBe(0); // only the silent Unknown→Running on the first, no events after
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_callback_for_non_tracked_probe_is_dropped()
|
||||
{
|
||||
var subs = new FakeSubscriber();
|
||||
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe);
|
||||
|
||||
mgr.OnProbeCallback("ProbeForSomeoneElse.ScanState", Good(true));
|
||||
|
||||
mgr.ActiveProbeCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_reports_current_state_for_every_tracked_host()
|
||||
{
|
||||
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||
var subs = new FakeSubscriber();
|
||||
using var mgr = new GalaxyRuntimeProbeManager(subs.Subscribe, subs.Unsubscribe, () => now);
|
||||
|
||||
await mgr.SyncAsync(new[]
|
||||
{
|
||||
new HostProbeTarget("PlatformA", 1),
|
||||
new HostProbeTarget("EngineB", 3),
|
||||
});
|
||||
subs.Subs["PlatformA.ScanState"]("PlatformA.ScanState", Good(true)); // Running
|
||||
subs.Subs["EngineB.ScanState"]("EngineB.ScanState", Bad()); // Stopped
|
||||
|
||||
var snap = mgr.SnapshotStates();
|
||||
snap.Count.ShouldBe(2);
|
||||
snap.ShouldContain(s => s.TagName == "PlatformA" && s.State == HostRuntimeState.Running);
|
||||
snap.ShouldContain(s => s.TagName == "EngineB" && s.State == HostRuntimeState.Stopped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRuntimeHost_recognizes_WinPlatform_and_AppEngine_category_ids()
|
||||
{
|
||||
new HostProbeTarget("X", GalaxyRuntimeProbeManager.CategoryWinPlatform).IsRuntimeHost.ShouldBeTrue();
|
||||
new HostProbeTarget("X", GalaxyRuntimeProbeManager.CategoryAppEngine).IsRuntimeHost.ShouldBeTrue();
|
||||
new HostProbeTarget("X", 4 /* $Area */).IsRuntimeHost.ShouldBeFalse();
|
||||
new HostProbeTarget("X", 11 /* $ApplicationObject */).IsRuntimeHost.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
|
||||
{
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HistorianClusterEndpointPickerTests
|
||||
{
|
||||
private static HistorianConfiguration Config(params string[] nodes) => new()
|
||||
{
|
||||
ServerName = "ignored",
|
||||
ServerNames = nodes.ToList(),
|
||||
FailureCooldownSeconds = 60,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Single_node_config_falls_back_to_ServerName_when_ServerNames_empty()
|
||||
{
|
||||
var cfg = new HistorianConfiguration { ServerName = "only-node", ServerNames = new() };
|
||||
var p = new HistorianClusterEndpointPicker(cfg);
|
||||
p.NodeCount.ShouldBe(1);
|
||||
p.GetHealthyNodes().ShouldBe(new[] { "only-node" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_node_enters_cooldown_and_is_skipped()
|
||||
{
|
||||
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||
var p = new HistorianClusterEndpointPicker(Config("a", "b"), () => now);
|
||||
|
||||
p.MarkFailed("a", "boom");
|
||||
p.GetHealthyNodes().ShouldBe(new[] { "b" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cooldown_expires_after_configured_window()
|
||||
{
|
||||
var clock = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||
var p = new HistorianClusterEndpointPicker(Config("a", "b"), () => clock);
|
||||
p.MarkFailed("a", "boom");
|
||||
p.GetHealthyNodes().ShouldBe(new[] { "b" });
|
||||
|
||||
clock = clock.AddSeconds(61);
|
||||
p.GetHealthyNodes().ShouldBe(new[] { "a", "b" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkHealthy_immediately_clears_cooldown()
|
||||
{
|
||||
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||
var p = new HistorianClusterEndpointPicker(Config("a"), () => now);
|
||||
p.MarkFailed("a", "boom");
|
||||
p.GetHealthyNodes().ShouldBeEmpty();
|
||||
p.MarkHealthy("a");
|
||||
p.GetHealthyNodes().ShouldBe(new[] { "a" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void All_nodes_in_cooldown_returns_empty_healthy_list()
|
||||
{
|
||||
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||
var p = new HistorianClusterEndpointPicker(Config("a", "b"), () => now);
|
||||
p.MarkFailed("a", "x");
|
||||
p.MarkFailed("b", "y");
|
||||
p.GetHealthyNodes().ShouldBeEmpty();
|
||||
p.NodeCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_reports_failure_count_and_last_error()
|
||||
{
|
||||
var now = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||
var p = new HistorianClusterEndpointPicker(Config("a"), () => now);
|
||||
p.MarkFailed("a", "first");
|
||||
p.MarkFailed("a", "second");
|
||||
|
||||
var snap = p.SnapshotNodeStates().Single();
|
||||
snap.FailureCount.ShouldBe(2);
|
||||
snap.LastError.ShouldBe("second");
|
||||
snap.IsHealthy.ShouldBeFalse();
|
||||
snap.CooldownUntil.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Duplicate_hostnames_are_deduplicated_case_insensitively()
|
||||
{
|
||||
var p = new HistorianClusterEndpointPicker(Config("NodeA", "nodea", "NodeB"));
|
||||
p.NodeCount.ShouldBe(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HistorianQualityMapperTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Rich mapping preserves specific OPC DA subcodes through the historian ToWire path.
|
||||
/// Before PR 12 the category-only fallback collapsed e.g. BadNotConnected(8) to
|
||||
/// Bad(0x80000000) so downstream OPC UA clients could not distinguish transport issues
|
||||
/// from sensor issues. After PR 12 every known subcode round-trips to its canonical
|
||||
/// uint32 StatusCode and Proxy translation stays byte-for-byte with v1 QualityMapper.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData((byte)192, 0x00000000u)] // Good
|
||||
[InlineData((byte)216, 0x00D80000u)] // Good_LocalOverride
|
||||
[InlineData((byte)64, 0x40000000u)] // Uncertain
|
||||
[InlineData((byte)68, 0x40900000u)] // Uncertain_LastUsableValue
|
||||
[InlineData((byte)80, 0x40930000u)] // Uncertain_SensorNotAccurate
|
||||
[InlineData((byte)84, 0x40940000u)] // Uncertain_EngineeringUnitsExceeded
|
||||
[InlineData((byte)88, 0x40950000u)] // Uncertain_SubNormal
|
||||
[InlineData((byte)0, 0x80000000u)] // Bad
|
||||
[InlineData((byte)4, 0x80890000u)] // Bad_ConfigurationError
|
||||
[InlineData((byte)8, 0x808A0000u)] // Bad_NotConnected
|
||||
[InlineData((byte)12, 0x808B0000u)] // Bad_DeviceFailure
|
||||
[InlineData((byte)16, 0x808C0000u)] // Bad_SensorFailure
|
||||
[InlineData((byte)20, 0x80050000u)] // Bad_CommunicationError
|
||||
[InlineData((byte)24, 0x808D0000u)] // Bad_OutOfService
|
||||
[InlineData((byte)32, 0x80320000u)] // Bad_WaitingForInitialData
|
||||
public void Maps_specific_OPC_DA_codes_to_canonical_StatusCode(byte quality, uint expected)
|
||||
{
|
||||
HistorianQualityMapper.Map(quality).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((byte)200)] // Good — unknown subcode in Good family
|
||||
[InlineData((byte)255)] // Good — unknown
|
||||
public void Unknown_good_family_codes_fall_back_to_plain_Good(byte q)
|
||||
{
|
||||
HistorianQualityMapper.Map(q).ShouldBe(0x00000000u);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((byte)100)] // Uncertain — unknown subcode
|
||||
[InlineData((byte)150)] // Uncertain — unknown
|
||||
public void Unknown_uncertain_family_codes_fall_back_to_plain_Uncertain(byte q)
|
||||
{
|
||||
HistorianQualityMapper.Map(q).ShouldBe(0x40000000u);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((byte)1)] // Bad — unknown subcode
|
||||
[InlineData((byte)50)] // Bad — unknown
|
||||
public void Unknown_bad_family_codes_fall_back_to_plain_Bad(byte q)
|
||||
{
|
||||
HistorianQualityMapper.Map(q).ShouldBe(0x80000000u);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
|
||||
{
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HistorianWiringTests
|
||||
{
|
||||
/// <summary>
|
||||
/// When the Proxy sends a HistoryRead but the supervisor never enabled the historian
|
||||
/// (OTOPCUA_HISTORIAN_ENABLED unset), we expect a clean Success=false with a
|
||||
/// self-explanatory error — not an exception or a hang against localhost.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task HistoryReadAsync_returns_disabled_error_when_no_historian_configured()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
var mx = new MxAccessClient(pump, new MxProxyAdapter(), "HistorianWiringTests");
|
||||
using var backend = new MxAccessGalaxyBackend(
|
||||
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
|
||||
mx,
|
||||
historian: null);
|
||||
|
||||
var resp = await backend.HistoryReadAsync(new HistoryReadRequest
|
||||
{
|
||||
TagReferences = new[] { "TestTag" },
|
||||
StartUtcUnixMs = 0,
|
||||
EndUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
MaxValuesPerTag = 100,
|
||||
}, CancellationToken.None);
|
||||
|
||||
resp.Success.ShouldBeFalse();
|
||||
resp.Error.ShouldContain("Historian disabled");
|
||||
resp.Tags.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the historian is wired up, we expect the backend to call through and map
|
||||
/// samples onto the IPC wire shape. Uses a fake <see cref="IHistorianDataSource"/>
|
||||
/// that returns a single known-good sample so we can assert the mapping stays sane.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task HistoryReadAsync_maps_sample_to_GalaxyDataValue()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
var mx = new MxAccessClient(pump, new MxProxyAdapter(), "HistorianWiringTests");
|
||||
var fake = new FakeHistorianDataSource(new HistorianSample
|
||||
{
|
||||
Value = 42.5,
|
||||
Quality = 192, // Good
|
||||
TimestampUtc = new DateTime(2026, 4, 18, 9, 0, 0, DateTimeKind.Utc),
|
||||
});
|
||||
using var backend = new MxAccessGalaxyBackend(
|
||||
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
|
||||
mx,
|
||||
fake);
|
||||
|
||||
var resp = await backend.HistoryReadAsync(new HistoryReadRequest
|
||||
{
|
||||
TagReferences = new[] { "TankLevel" },
|
||||
StartUtcUnixMs = 0,
|
||||
EndUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
MaxValuesPerTag = 100,
|
||||
}, CancellationToken.None);
|
||||
|
||||
resp.Success.ShouldBeTrue();
|
||||
resp.Tags.Length.ShouldBe(1);
|
||||
resp.Tags[0].TagReference.ShouldBe("TankLevel");
|
||||
resp.Tags[0].Values.Length.ShouldBe(1);
|
||||
resp.Tags[0].Values[0].StatusCode.ShouldBe(0u); // Good
|
||||
resp.Tags[0].Values[0].ValueBytes.ShouldNotBeNull();
|
||||
resp.Tags[0].Values[0].SourceTimestampUtcUnixMs.ShouldBe(
|
||||
new DateTimeOffset(2026, 4, 18, 9, 0, 0, TimeSpan.Zero).ToUnixTimeMilliseconds());
|
||||
}
|
||||
|
||||
private sealed class FakeHistorianDataSource : IHistorianDataSource
|
||||
{
|
||||
private readonly HistorianSample _sample;
|
||||
public FakeHistorianDataSource(HistorianSample sample) => _sample = sample;
|
||||
|
||||
public Task<List<HistorianSample>> ReadRawAsync(string tagName, DateTime s, DateTime e, int max, CancellationToken ct)
|
||||
=> Task.FromResult(new List<HistorianSample> { _sample });
|
||||
|
||||
public Task<List<HistorianAggregateSample>> ReadAggregateAsync(string tagName, DateTime s, DateTime e, double ms, string col, CancellationToken ct)
|
||||
=> Task.FromResult(new List<HistorianAggregateSample>());
|
||||
|
||||
public Task<List<HistorianSample>> ReadAtTimeAsync(string tagName, DateTime[] ts, CancellationToken ct)
|
||||
=> Task.FromResult(new List<HistorianSample>());
|
||||
|
||||
public Task<List<HistorianEventDto>> ReadEventsAsync(string? src, DateTime s, DateTime e, int max, CancellationToken ct)
|
||||
=> Task.FromResult(new List<HistorianEventDto>());
|
||||
|
||||
public HistorianHealthSnapshot GetHealthSnapshot() => new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HistoryReadAtTimeTests
|
||||
{
|
||||
private static MxAccessGalaxyBackend BuildBackend(IHistorianDataSource? historian, StaPump pump) =>
|
||||
new(
|
||||
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
|
||||
new MxAccessClient(pump, new MxProxyAdapter(), "attime-test"),
|
||||
historian);
|
||||
|
||||
[Fact]
|
||||
public async Task Returns_disabled_error_when_no_historian_configured()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
using var backend = BuildBackend(null, pump);
|
||||
|
||||
var resp = await backend.HistoryReadAtTimeAsync(new HistoryReadAtTimeRequest
|
||||
{
|
||||
TagReference = "T",
|
||||
TimestampsUtcUnixMs = new[] { 1L, 2L },
|
||||
}, CancellationToken.None);
|
||||
|
||||
resp.Success.ShouldBeFalse();
|
||||
resp.Error.ShouldContain("Historian disabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_timestamp_list_short_circuits_to_success_with_no_values()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
var fake = new FakeHistorian();
|
||||
using var backend = BuildBackend(fake, pump);
|
||||
|
||||
var resp = await backend.HistoryReadAtTimeAsync(new HistoryReadAtTimeRequest
|
||||
{
|
||||
TagReference = "T",
|
||||
TimestampsUtcUnixMs = Array.Empty<long>(),
|
||||
}, CancellationToken.None);
|
||||
|
||||
resp.Success.ShouldBeTrue();
|
||||
resp.Values.ShouldBeEmpty();
|
||||
fake.Calls.ShouldBe(0); // no round-trip to SDK for empty timestamp list
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timestamps_survive_Unix_ms_round_trip_to_DateTime()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
var t1 = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||
var t2 = new DateTime(2026, 4, 18, 10, 5, 0, DateTimeKind.Utc);
|
||||
var fake = new FakeHistorian(
|
||||
new HistorianSample { Value = 100.0, Quality = 192, TimestampUtc = t1 },
|
||||
new HistorianSample { Value = 101.5, Quality = 192, TimestampUtc = t2 });
|
||||
using var backend = BuildBackend(fake, pump);
|
||||
|
||||
var resp = await backend.HistoryReadAtTimeAsync(new HistoryReadAtTimeRequest
|
||||
{
|
||||
TagReference = "TankLevel",
|
||||
TimestampsUtcUnixMs = new[]
|
||||
{
|
||||
new DateTimeOffset(t1, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
new DateTimeOffset(t2, TimeSpan.Zero).ToUnixTimeMilliseconds(),
|
||||
},
|
||||
}, CancellationToken.None);
|
||||
|
||||
resp.Success.ShouldBeTrue();
|
||||
resp.Values.Length.ShouldBe(2);
|
||||
resp.Values[0].SourceTimestampUtcUnixMs.ShouldBe(new DateTimeOffset(t1, TimeSpan.Zero).ToUnixTimeMilliseconds());
|
||||
resp.Values[0].StatusCode.ShouldBe(0u); // Good (quality 192)
|
||||
MessagePackSerializer.Deserialize<double>(resp.Values[0].ValueBytes!).ShouldBe(100.0);
|
||||
|
||||
fake.Calls.ShouldBe(1);
|
||||
fake.LastTimestamps.Length.ShouldBe(2);
|
||||
fake.LastTimestamps[0].ShouldBe(t1);
|
||||
fake.LastTimestamps[1].ShouldBe(t2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Missing_sample_maps_to_Bad_category()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
// Quality=0 means no sample at that timestamp per HistorianDataSource.ReadAtTimeAsync.
|
||||
var fake = new FakeHistorian(new HistorianSample
|
||||
{
|
||||
Value = null,
|
||||
Quality = 0,
|
||||
TimestampUtc = DateTime.UtcNow,
|
||||
});
|
||||
using var backend = BuildBackend(fake, pump);
|
||||
|
||||
var resp = await backend.HistoryReadAtTimeAsync(new HistoryReadAtTimeRequest
|
||||
{
|
||||
TagReference = "T",
|
||||
TimestampsUtcUnixMs = new[] { 1L },
|
||||
}, CancellationToken.None);
|
||||
|
||||
resp.Success.ShouldBeTrue();
|
||||
resp.Values.Length.ShouldBe(1);
|
||||
resp.Values[0].StatusCode.ShouldBe(0x80000000u); // Bad category
|
||||
resp.Values[0].ValueBytes.ShouldBeNull();
|
||||
}
|
||||
|
||||
private sealed class FakeHistorian : IHistorianDataSource
|
||||
{
|
||||
private readonly HistorianSample[] _samples;
|
||||
public int Calls { get; private set; }
|
||||
public DateTime[] LastTimestamps { get; private set; } = Array.Empty<DateTime>();
|
||||
|
||||
public FakeHistorian(params HistorianSample[] samples) => _samples = samples;
|
||||
|
||||
public Task<List<HistorianSample>> ReadAtTimeAsync(string tag, DateTime[] ts, CancellationToken ct)
|
||||
{
|
||||
Calls++;
|
||||
LastTimestamps = ts;
|
||||
return Task.FromResult(new List<HistorianSample>(_samples));
|
||||
}
|
||||
|
||||
public Task<List<HistorianSample>> ReadRawAsync(string tag, DateTime s, DateTime e, int max, CancellationToken ct)
|
||||
=> Task.FromResult(new List<HistorianSample>());
|
||||
public Task<List<HistorianAggregateSample>> ReadAggregateAsync(string tag, DateTime s, DateTime e, double ms, string col, CancellationToken ct)
|
||||
=> Task.FromResult(new List<HistorianAggregateSample>());
|
||||
public Task<List<HistorianEventDto>> ReadEventsAsync(string? src, DateTime s, DateTime e, int max, CancellationToken ct)
|
||||
=> Task.FromResult(new List<HistorianEventDto>());
|
||||
public HistorianHealthSnapshot GetHealthSnapshot() => new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HistoryReadEventsTests
|
||||
{
|
||||
private static MxAccessGalaxyBackend BuildBackend(IHistorianDataSource? h, StaPump pump) =>
|
||||
new(
|
||||
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
|
||||
new MxAccessClient(pump, new MxProxyAdapter(), "events-test"),
|
||||
h);
|
||||
|
||||
[Fact]
|
||||
public async Task Returns_disabled_error_when_no_historian_configured()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
using var backend = BuildBackend(null, pump);
|
||||
|
||||
var resp = await backend.HistoryReadEventsAsync(new HistoryReadEventsRequest
|
||||
{
|
||||
SourceName = "TankA",
|
||||
StartUtcUnixMs = 0,
|
||||
EndUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
MaxEvents = 100,
|
||||
}, CancellationToken.None);
|
||||
|
||||
resp.Success.ShouldBeFalse();
|
||||
resp.Error.ShouldContain("Historian disabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Maps_HistorianEventDto_to_GalaxyHistoricalEvent_wire_shape()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
|
||||
var eventId = Guid.NewGuid();
|
||||
var eventTime = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc);
|
||||
var receivedTime = eventTime.AddMilliseconds(150);
|
||||
var fake = new FakeHistorian(new HistorianEventDto
|
||||
{
|
||||
Id = eventId,
|
||||
Source = "TankA.Level.HiHi",
|
||||
EventTime = eventTime,
|
||||
ReceivedTime = receivedTime,
|
||||
DisplayText = "HiHi alarm tripped",
|
||||
Severity = 900,
|
||||
});
|
||||
using var backend = BuildBackend(fake, pump);
|
||||
|
||||
var resp = await backend.HistoryReadEventsAsync(new HistoryReadEventsRequest
|
||||
{
|
||||
SourceName = "TankA.Level.HiHi",
|
||||
StartUtcUnixMs = 0,
|
||||
EndUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
MaxEvents = 50,
|
||||
}, CancellationToken.None);
|
||||
|
||||
resp.Success.ShouldBeTrue();
|
||||
resp.Events.Length.ShouldBe(1);
|
||||
var got = resp.Events[0];
|
||||
got.EventId.ShouldBe(eventId.ToString());
|
||||
got.SourceName.ShouldBe("TankA.Level.HiHi");
|
||||
got.DisplayText.ShouldBe("HiHi alarm tripped");
|
||||
got.Severity.ShouldBe<ushort>(900);
|
||||
got.EventTimeUtcUnixMs.ShouldBe(new DateTimeOffset(eventTime, TimeSpan.Zero).ToUnixTimeMilliseconds());
|
||||
got.ReceivedTimeUtcUnixMs.ShouldBe(new DateTimeOffset(receivedTime, TimeSpan.Zero).ToUnixTimeMilliseconds());
|
||||
|
||||
fake.LastSourceName.ShouldBe("TankA.Level.HiHi");
|
||||
fake.LastMaxEvents.ShouldBe(50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Null_source_name_is_passed_through_as_all_sources()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
var fake = new FakeHistorian();
|
||||
using var backend = BuildBackend(fake, pump);
|
||||
|
||||
await backend.HistoryReadEventsAsync(new HistoryReadEventsRequest
|
||||
{
|
||||
SourceName = null,
|
||||
StartUtcUnixMs = 0,
|
||||
EndUtcUnixMs = 1,
|
||||
MaxEvents = 10,
|
||||
}, CancellationToken.None);
|
||||
|
||||
fake.LastSourceName.ShouldBeNull();
|
||||
}
|
||||
|
||||
private sealed class FakeHistorian : IHistorianDataSource
|
||||
{
|
||||
private readonly HistorianEventDto[] _events;
|
||||
public string? LastSourceName { get; private set; } = "<unset>";
|
||||
public int LastMaxEvents { get; private set; }
|
||||
|
||||
public FakeHistorian(params HistorianEventDto[] events) => _events = events;
|
||||
|
||||
public Task<List<HistorianEventDto>> ReadEventsAsync(string? src, DateTime s, DateTime e, int max, CancellationToken ct)
|
||||
{
|
||||
LastSourceName = src;
|
||||
LastMaxEvents = max;
|
||||
return Task.FromResult(new List<HistorianEventDto>(_events));
|
||||
}
|
||||
|
||||
public Task<List<HistorianSample>> ReadRawAsync(string tag, DateTime s, DateTime e, int max, CancellationToken ct)
|
||||
=> Task.FromResult(new List<HistorianSample>());
|
||||
public Task<List<HistorianAggregateSample>> ReadAggregateAsync(string tag, DateTime s, DateTime e, double ms, string col, CancellationToken ct)
|
||||
=> Task.FromResult(new List<HistorianAggregateSample>());
|
||||
public Task<List<HistorianSample>> ReadAtTimeAsync(string tag, DateTime[] ts, CancellationToken ct)
|
||||
=> Task.FromResult(new List<HistorianSample>());
|
||||
public HistorianHealthSnapshot GetHealthSnapshot() => new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HistoryReadProcessedTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReturnsDisabledError_When_NoHistorianConfigured()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
var mx = new MxAccessClient(pump, new MxProxyAdapter(), "processed-test");
|
||||
using var backend = new MxAccessGalaxyBackend(
|
||||
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
|
||||
mx,
|
||||
historian: null);
|
||||
|
||||
var resp = await backend.HistoryReadProcessedAsync(new HistoryReadProcessedRequest
|
||||
{
|
||||
TagReference = "T",
|
||||
StartUtcUnixMs = 0,
|
||||
EndUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
IntervalMs = 1000,
|
||||
AggregateColumn = "Average",
|
||||
}, CancellationToken.None);
|
||||
|
||||
resp.Success.ShouldBeFalse();
|
||||
resp.Error.ShouldContain("Historian disabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_NonPositiveInterval()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
var mx = new MxAccessClient(pump, new MxProxyAdapter(), "processed-test");
|
||||
var fake = new FakeHistorianDataSource();
|
||||
using var backend = new MxAccessGalaxyBackend(
|
||||
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
|
||||
mx,
|
||||
fake);
|
||||
|
||||
var resp = await backend.HistoryReadProcessedAsync(new HistoryReadProcessedRequest
|
||||
{
|
||||
TagReference = "T",
|
||||
IntervalMs = 0,
|
||||
AggregateColumn = "Average",
|
||||
}, CancellationToken.None);
|
||||
|
||||
resp.Success.ShouldBeFalse();
|
||||
resp.Error.ShouldContain("IntervalMs");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Maps_AggregateSample_With_Value_To_Good()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
var mx = new MxAccessClient(pump, new MxProxyAdapter(), "processed-test");
|
||||
var fake = new FakeHistorianDataSource(new HistorianAggregateSample
|
||||
{
|
||||
Value = 12.34,
|
||||
TimestampUtc = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc),
|
||||
});
|
||||
using var backend = new MxAccessGalaxyBackend(
|
||||
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
|
||||
mx,
|
||||
fake);
|
||||
|
||||
var resp = await backend.HistoryReadProcessedAsync(new HistoryReadProcessedRequest
|
||||
{
|
||||
TagReference = "T",
|
||||
StartUtcUnixMs = 0,
|
||||
EndUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
IntervalMs = 60_000,
|
||||
AggregateColumn = "Average",
|
||||
}, CancellationToken.None);
|
||||
|
||||
resp.Success.ShouldBeTrue();
|
||||
resp.Values.Length.ShouldBe(1);
|
||||
resp.Values[0].StatusCode.ShouldBe(0u); // Good
|
||||
resp.Values[0].ValueBytes.ShouldNotBeNull();
|
||||
MessagePackSerializer.Deserialize<double>(resp.Values[0].ValueBytes!).ShouldBe(12.34);
|
||||
fake.LastAggregateColumn.ShouldBe("Average");
|
||||
fake.LastIntervalMs.ShouldBe(60_000d);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Maps_Null_Bucket_To_BadNoData()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
var mx = new MxAccessClient(pump, new MxProxyAdapter(), "processed-test");
|
||||
var fake = new FakeHistorianDataSource(new HistorianAggregateSample
|
||||
{
|
||||
Value = null,
|
||||
TimestampUtc = DateTime.UtcNow,
|
||||
});
|
||||
using var backend = new MxAccessGalaxyBackend(
|
||||
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
|
||||
mx,
|
||||
fake);
|
||||
|
||||
var resp = await backend.HistoryReadProcessedAsync(new HistoryReadProcessedRequest
|
||||
{
|
||||
TagReference = "T",
|
||||
IntervalMs = 1000,
|
||||
AggregateColumn = "Minimum",
|
||||
}, CancellationToken.None);
|
||||
|
||||
resp.Success.ShouldBeTrue();
|
||||
resp.Values.Length.ShouldBe(1);
|
||||
resp.Values[0].StatusCode.ShouldBe(0x800E0000u); // BadNoData
|
||||
resp.Values[0].ValueBytes.ShouldBeNull();
|
||||
}
|
||||
|
||||
private sealed class FakeHistorianDataSource : IHistorianDataSource
|
||||
{
|
||||
private readonly HistorianAggregateSample[] _samples;
|
||||
public string? LastAggregateColumn { get; private set; }
|
||||
public double LastIntervalMs { get; private set; }
|
||||
|
||||
public FakeHistorianDataSource(params HistorianAggregateSample[] samples) => _samples = samples;
|
||||
|
||||
public Task<List<HistorianSample>> ReadRawAsync(string tag, DateTime s, DateTime e, int max, CancellationToken ct)
|
||||
=> Task.FromResult(new List<HistorianSample>());
|
||||
|
||||
public Task<List<HistorianAggregateSample>> ReadAggregateAsync(
|
||||
string tag, DateTime s, DateTime e, double intervalMs, string col, CancellationToken ct)
|
||||
{
|
||||
LastAggregateColumn = col;
|
||||
LastIntervalMs = intervalMs;
|
||||
return Task.FromResult(new List<HistorianAggregateSample>(_samples));
|
||||
}
|
||||
|
||||
public Task<List<HistorianSample>> ReadAtTimeAsync(string tag, DateTime[] ts, CancellationToken ct)
|
||||
=> Task.FromResult(new List<HistorianSample>());
|
||||
|
||||
public Task<List<HistorianEventDto>> ReadEventsAsync(string? src, DateTime s, DateTime e, int max, CancellationToken ct)
|
||||
=> Task.FromResult(new List<HistorianEventDto>());
|
||||
|
||||
public HistorianHealthSnapshot GetHealthSnapshot() => new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ArchestrA.MxAccess;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HostStatusPushTests
|
||||
{
|
||||
/// <summary>
|
||||
/// PR 8 — when MxAccessClient.ConnectionStateChanged fires false→true→false,
|
||||
/// MxAccessGalaxyBackend raises OnHostStatusChanged once per transition with
|
||||
/// HostName=ClientName, RuntimeStatus="Running"/"Stopped", and a timestamp.
|
||||
/// This is the gateway-level signal; per-platform ScanState probes are deferred.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ConnectionStateChanged_raises_OnHostStatusChanged_with_gateway_name()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
var proxy = new FakeProxy();
|
||||
var mx = new MxAccessClient(pump, proxy, "GatewayClient", new MxAccessClientOptions { AutoReconnect = false });
|
||||
using var backend = new MxAccessGalaxyBackend(
|
||||
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
|
||||
mx,
|
||||
historian: null);
|
||||
|
||||
var notifications = new ConcurrentQueue<HostConnectivityStatus>();
|
||||
backend.OnHostStatusChanged += (_, s) => notifications.Enqueue(s);
|
||||
|
||||
await mx.ConnectAsync();
|
||||
await mx.DisconnectAsync();
|
||||
|
||||
notifications.Count.ShouldBe(2);
|
||||
notifications.TryDequeue(out var first).ShouldBeTrue();
|
||||
first!.HostName.ShouldBe("GatewayClient");
|
||||
first.RuntimeStatus.ShouldBe("Running");
|
||||
first.LastObservedUtcUnixMs.ShouldBeGreaterThan(0);
|
||||
|
||||
notifications.TryDequeue(out var second).ShouldBeTrue();
|
||||
second!.HostName.ShouldBe("GatewayClient");
|
||||
second.RuntimeStatus.ShouldBe("Stopped");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_unsubscribes_so_post_dispose_state_changes_do_not_fire_events()
|
||||
{
|
||||
using var pump = new StaPump("Test.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
var proxy = new FakeProxy();
|
||||
var mx = new MxAccessClient(pump, proxy, "GatewayClient", new MxAccessClientOptions { AutoReconnect = false });
|
||||
var backend = new MxAccessGalaxyBackend(
|
||||
new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
|
||||
mx,
|
||||
historian: null);
|
||||
|
||||
var count = 0;
|
||||
backend.OnHostStatusChanged += (_, _) => Interlocked.Increment(ref count);
|
||||
|
||||
await mx.ConnectAsync();
|
||||
count.ShouldBe(1);
|
||||
|
||||
backend.Dispose();
|
||||
await mx.DisconnectAsync();
|
||||
|
||||
count.ShouldBe(1); // no second notification after Dispose
|
||||
}
|
||||
|
||||
private sealed class FakeProxy : IMxProxy
|
||||
{
|
||||
private int _next = 1;
|
||||
public int Register(string _) => 42;
|
||||
public void Unregister(int _) { }
|
||||
public int AddItem(int _, string __) => Interlocked.Increment(ref _next);
|
||||
public void RemoveItem(int _, int __) { }
|
||||
public void AdviseSupervisory(int _, int __) { }
|
||||
public void UnAdviseSupervisory(int _, int __) { }
|
||||
public void Write(int _, int __, object ___, int ____) { }
|
||||
public event MxDataChangeHandler? OnDataChange { add { } remove { } }
|
||||
public event MxWriteCompleteHandler? OnWriteComplete { add { } remove { } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ArchestrA.MxAccess;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MxAccessClientMonitorLoopTests
|
||||
{
|
||||
/// <summary>
|
||||
/// PR 6 low finding #1 — every $Heartbeat probe must RemoveItem the item handle it
|
||||
/// allocated. Without that, the monitor leaks one handle per MonitorInterval seconds,
|
||||
/// which over a 24h uptime becomes thousands of leaked MXAccess handles and can
|
||||
/// eventually exhaust the runtime proxy's handle table.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Heartbeat_probe_calls_RemoveItem_for_every_AddItem()
|
||||
{
|
||||
using var pump = new StaPump("Monitor.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
|
||||
var proxy = new CountingProxy();
|
||||
var client = new MxAccessClient(pump, proxy, "probe-test", new MxAccessClientOptions
|
||||
{
|
||||
AutoReconnect = true,
|
||||
MonitorInterval = TimeSpan.FromMilliseconds(150),
|
||||
StaleThreshold = TimeSpan.FromMilliseconds(50),
|
||||
});
|
||||
|
||||
await client.ConnectAsync();
|
||||
|
||||
// Wait past StaleThreshold, then let several monitor cycles fire.
|
||||
await Task.Delay(700);
|
||||
|
||||
client.Dispose();
|
||||
|
||||
// One Heartbeat probe fires per monitor tick once the connection looks stale.
|
||||
proxy.HeartbeatAddCount.ShouldBeGreaterThan(1);
|
||||
// Every AddItem("$Heartbeat") must be matched by a RemoveItem on the same handle.
|
||||
proxy.HeartbeatAddCount.ShouldBe(proxy.HeartbeatRemoveCount);
|
||||
proxy.OutstandingHeartbeatHandles.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR 6 low finding #2 — after reconnect, per-subscription replay failures must raise
|
||||
/// SubscriptionReplayFailed so the backend can propagate the degradation, not get
|
||||
/// silently eaten.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SubscriptionReplayFailed_fires_for_each_tag_that_fails_to_replay()
|
||||
{
|
||||
using var pump = new StaPump("Replay.Sta");
|
||||
await pump.WaitForStartedAsync();
|
||||
|
||||
var proxy = new ReplayFailingProxy(failOnReplayForTags: new[] { "BadTag.A", "BadTag.B" });
|
||||
var client = new MxAccessClient(pump, proxy, "replay-test", new MxAccessClientOptions
|
||||
{
|
||||
AutoReconnect = true,
|
||||
MonitorInterval = TimeSpan.FromMilliseconds(120),
|
||||
StaleThreshold = TimeSpan.FromMilliseconds(50),
|
||||
});
|
||||
|
||||
var failures = new ConcurrentBag<SubscriptionReplayFailedEventArgs>();
|
||||
client.SubscriptionReplayFailed += (_, e) => failures.Add(e);
|
||||
|
||||
await client.ConnectAsync();
|
||||
await client.SubscribeAsync("GoodTag.X", (_, _) => { });
|
||||
await client.SubscribeAsync("BadTag.A", (_, _) => { });
|
||||
await client.SubscribeAsync("BadTag.B", (_, _) => { });
|
||||
|
||||
proxy.TriggerProbeFailureOnNextCall();
|
||||
|
||||
// Wait for the monitor loop to probe → fail → reconnect → replay.
|
||||
await Task.Delay(800);
|
||||
|
||||
client.Dispose();
|
||||
|
||||
failures.Count.ShouldBe(2);
|
||||
var names = new HashSet<string>();
|
||||
foreach (var f in failures) names.Add(f.TagReference);
|
||||
names.ShouldContain("BadTag.A");
|
||||
names.ShouldContain("BadTag.B");
|
||||
}
|
||||
|
||||
// ----- test doubles -----
|
||||
|
||||
private sealed class CountingProxy : IMxProxy
|
||||
{
|
||||
private int _next = 1;
|
||||
private readonly ConcurrentDictionary<int, string> _live = new();
|
||||
|
||||
public int HeartbeatAddCount;
|
||||
public int HeartbeatRemoveCount;
|
||||
public int OutstandingHeartbeatHandles => _live.Count;
|
||||
|
||||
public event MxDataChangeHandler? OnDataChange { add { } remove { } }
|
||||
public event MxWriteCompleteHandler? OnWriteComplete { add { } remove { } }
|
||||
|
||||
public int Register(string _) => 42;
|
||||
public void Unregister(int _) { }
|
||||
|
||||
public int AddItem(int _, string address)
|
||||
{
|
||||
var h = Interlocked.Increment(ref _next);
|
||||
_live[h] = address;
|
||||
if (address == "$Heartbeat") Interlocked.Increment(ref HeartbeatAddCount);
|
||||
return h;
|
||||
}
|
||||
|
||||
public void RemoveItem(int _, int itemHandle)
|
||||
{
|
||||
if (_live.TryRemove(itemHandle, out var addr) && addr == "$Heartbeat")
|
||||
Interlocked.Increment(ref HeartbeatRemoveCount);
|
||||
}
|
||||
|
||||
public void AdviseSupervisory(int _, int __) { }
|
||||
public void UnAdviseSupervisory(int _, int __) { }
|
||||
public void Write(int _, int __, object ___, int ____) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock that lets us exercise the reconnect + replay path. TriggerProbeFailureOnNextCall
|
||||
/// flips a one-shot flag so the very next AddItem("$Heartbeat") throws — that drives the
|
||||
/// monitor loop into the reconnect-with-replay branch. During the replay, AddItem for the
|
||||
/// tags listed in failOnReplayForTags throws so SubscriptionReplayFailed should fire once
|
||||
/// per failing tag.
|
||||
/// </summary>
|
||||
private sealed class ReplayFailingProxy : IMxProxy
|
||||
{
|
||||
private int _next = 1;
|
||||
private readonly HashSet<string> _failOnReplay;
|
||||
private int _probeFailOnce;
|
||||
private readonly ConcurrentDictionary<string, bool> _replayedOnce = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ReplayFailingProxy(IEnumerable<string> failOnReplayForTags)
|
||||
{
|
||||
_failOnReplay = new HashSet<string>(failOnReplayForTags, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public void TriggerProbeFailureOnNextCall() => Interlocked.Exchange(ref _probeFailOnce, 1);
|
||||
|
||||
public event MxDataChangeHandler? OnDataChange { add { } remove { } }
|
||||
public event MxWriteCompleteHandler? OnWriteComplete { add { } remove { } }
|
||||
|
||||
public int Register(string _) => 42;
|
||||
public void Unregister(int _) { }
|
||||
|
||||
public int AddItem(int _, string address)
|
||||
{
|
||||
if (address == "$Heartbeat" && Interlocked.Exchange(ref _probeFailOnce, 0) == 1)
|
||||
throw new InvalidOperationException("simulated probe failure");
|
||||
|
||||
// Fail only on the *replay* AddItem for listed tags — not the initial subscribe.
|
||||
if (_failOnReplay.Contains(address) && _replayedOnce.ContainsKey(address))
|
||||
throw new InvalidOperationException($"simulated replay failure for {address}");
|
||||
|
||||
if (_failOnReplay.Contains(address)) _replayedOnce[address] = true;
|
||||
return Interlocked.Increment(ref _next);
|
||||
}
|
||||
|
||||
public void RemoveItem(int _, int __) { }
|
||||
public void AdviseSupervisory(int _, int __) { }
|
||||
public void UnAdviseSupervisory(int _, int __) { }
|
||||
public void Write(int _, int __, object ___, int ____) { }
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,11 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj"/>
|
||||
<Reference Include="System.ServiceProcess"/>
|
||||
<!-- IMxProxy's delegate signatures mention ArchestrA.MxAccess.MXSTATUS_PROXY, so tests
|
||||
implementing the interface must resolve that type at compile time. -->
|
||||
<Reference Include="ArchestrA.MxAccess">
|
||||
<HintPath>..\..\lib\ArchestrA.MxAccess.dll</HintPath>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AggregateColumnMappingTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(HistoryAggregateType.Average, "Average")]
|
||||
[InlineData(HistoryAggregateType.Minimum, "Minimum")]
|
||||
[InlineData(HistoryAggregateType.Maximum, "Maximum")]
|
||||
[InlineData(HistoryAggregateType.Count, "ValueCount")]
|
||||
public void Maps_OpcUa_enum_to_AnalogSummary_column(HistoryAggregateType aggregate, string expected)
|
||||
{
|
||||
GalaxyProxyDriver.MapAggregateToColumn(aggregate).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Total_is_not_supported()
|
||||
{
|
||||
Should.Throw<System.NotSupportedException>(
|
||||
() => GalaxyProxyDriver.MapAggregateToColumn(HistoryAggregateType.Total));
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,14 @@
|
||||
<LangVersion>9.0</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<!--
|
||||
Phase 2 Stream D — V1 ARCHIVE. References v1 OtOpcUa.Host directly.
|
||||
Excluded from `dotnet test` solution runs; replaced by the v2
|
||||
OtOpcUa.Driver.Galaxy.E2E suite. To run explicitly:
|
||||
dotnet test tests/ZB.MOM.WW.OtOpcUa.IntegrationTests
|
||||
See docs/v2/V1_ARCHIVE_STATUS.md.
|
||||
-->
|
||||
<IsTestProject>false</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.IntegrationTests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user