Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 64d8838e18 | |||
| 69f02fed7f | |||
| 5ed26d2ec6 | |||
| 439b39463b | |||
| 62d01e76e5 | |||
| 32b872d5c7 | |||
| 89004c052c | |||
| 2baca785ad | |||
| 1d62709060 | |||
| 0b5a4a676e | |||
| edc984987b | |||
| 6126374594 | |||
| 38afc234ff |
@@ -0,0 +1,129 @@
|
||||
# Alarm tracking — v2 final architecture
|
||||
|
||||
This document describes how OtOpcUa surfaces alarms to OPC UA Part 9
|
||||
clients after the **alarms-over-gateway** epic
|
||||
([docs/plans/alarms-over-gateway.md](plans/alarms-over-gateway.md))
|
||||
landed. The v1 architecture (Galaxy.Host's COM-side `GalaxyAlarmTracker`)
|
||||
is preserved at [docs/v1/AlarmTracking.md](v1/AlarmTracking.md) for
|
||||
historical reference.
|
||||
|
||||
## Three alarm sources, one OPC UA Part 9 surface
|
||||
|
||||
| Source | Driver capability | Path |
|
||||
|----------------------------------|--------------------------|------|
|
||||
| **Galaxy MxAccess (driver-native)** | `GalaxyDriver : IAlarmSource` | gateway → worker → MxAccess alarm sink → `MX_EVENT_FAMILY_ON_ALARM_TRANSITION` → `EventPump` → driver `OnAlarmEvent` → `AlarmConditionService` |
|
||||
| **Galaxy sub-attribute fallback** | `IWritable` writes to `$Alarm*` sub-attributes | gateway data subscription → driver `OnDataChange` → `DriverNodeManager` ConditionSink → `AlarmConditionService` |
|
||||
| **Scripted alarms** | `Phase7EngineComposer` | server-side script evaluator → `Phase7EngineComposer.RouteToHistorianAsync` + `AlarmConditionService` |
|
||||
|
||||
All three converge on `AlarmConditionService` (`src/ZB.MOM.WW.OtOpcUa.Server/Alarms/AlarmConditionService.cs`),
|
||||
which owns the OPC UA Part 9 state machine and dispatches transitions
|
||||
to the OPC UA condition node managers. Driver-native transitions take
|
||||
precedence over sub-attribute synthesis when both arrive for the same
|
||||
condition — the dedup logic prefers the richer driver-native record
|
||||
because it carries the full operator + raise-time + category metadata
|
||||
that the value-driven path collapses.
|
||||
|
||||
## Galaxy driver path (driver-native)
|
||||
|
||||
Restored in PR B.2 of the epic. `GalaxyDriver` implements
|
||||
`IAlarmSource` with these surfaces:
|
||||
|
||||
- `SubscribeAlarmsAsync(sourceNodeIds)` → returns a sentinel handle.
|
||||
The driver doesn't multiplex per source-node-id today; every
|
||||
active handle observes the gateway's alarm-event stream. The
|
||||
server-side `AlarmConditionService` filters by source-node before
|
||||
raising the OPC UA condition.
|
||||
- `UnsubscribeAlarmsAsync(handle)` → symmetric handle removal.
|
||||
- `AcknowledgeAsync(requests)` → routes one gateway RPC per
|
||||
acknowledgement through `IGalaxyAlarmAcknowledger`. Production
|
||||
uses `GatewayGalaxyAlarmAcknowledger` calling
|
||||
`MxGatewayClient.AcknowledgeAlarmAsync` (PR E.2 SDK method).
|
||||
- `OnAlarmEvent` → bridges `EventPump.OnAlarmTransition` (PR B.1)
|
||||
onto `AlarmEventArgs`. Suppressed when no alarm subscription is
|
||||
active so untracked transitions don't leak through.
|
||||
|
||||
The proto contract carries the rich payload — alarm full reference,
|
||||
source-object reference, alarm-type-name, transition kind (Raise /
|
||||
Acknowledge / Clear / Retrigger), severity (raw MxAccess scale),
|
||||
original raise timestamp, transition timestamp, operator user,
|
||||
operator comment, alarm category, description. `MxAccessSeverityMapper`
|
||||
(PR B.1) translates the raw severity onto the four-bucket
|
||||
`AlarmSeverity` ladder — boundaries match v1's `GalaxyAlarmTracker`
|
||||
so customers see no surprise re-classification.
|
||||
|
||||
The richer fields surface on `Core.Abstractions.AlarmEventArgs` via
|
||||
the optional properties added in PR E.7 (`OperatorComment`,
|
||||
`OriginalRaiseTimestampUtc`, `AlarmCategory`). Consumers that don't
|
||||
need them are unaffected; consumers that do (Client.UI, Client.CLI
|
||||
verbose mode) read the new fields when present.
|
||||
|
||||
## Galaxy sub-attribute fallback
|
||||
|
||||
For Galaxy templates without `$Alarm*` extensions, the value-driven
|
||||
path stays in place: `DriverNodeManager` registers an
|
||||
`AlarmConditionState` per Galaxy variable that bears alarm-bearing
|
||||
sub-attributes (`InAlarm`, `Acked`, `Priority`, `Description`),
|
||||
subscribes to those sub-attributes, and synthesizes Part 9 transitions
|
||||
when the values change. This path operated as the only Galaxy alarm
|
||||
path between PR 7.2 and the alarms-over-gateway epic; it remains the
|
||||
fallback today.
|
||||
|
||||
When both paths report the same condition,
|
||||
`AlarmConditionService.AlarmConditionState` keeps the
|
||||
driver-native record and discards the duplicate sub-attribute
|
||||
synthesis. Driver-native transitions are richer (carry operator
|
||||
comment + original raise time) and arrive lower-latency (no
|
||||
publishing-interval delay on the sub-attribute reads), so they win
|
||||
the dedup.
|
||||
|
||||
## Acknowledge routing
|
||||
|
||||
`DriverNodeManager` picks the acknowledger when registering each
|
||||
condition (PR B.3 logic):
|
||||
|
||||
- Driver implements `IAlarmSource` →
|
||||
`DriverAlarmSourceAcknowledger` routes the operator comment
|
||||
through `IAlarmSource.AcknowledgeAsync` via the existing
|
||||
`AlarmSurfaceInvoker` (Phase 6.1 resilience pipeline; no-retry
|
||||
per decision #143). End-to-end operator-comment fidelity is
|
||||
preserved.
|
||||
- Driver doesn't implement `IAlarmSource` →
|
||||
`DriverWritableAcknowledger` writes the comment into the
|
||||
`AckMsgWriteRef` sub-attribute via `IWritable.WriteAsync`. Same
|
||||
resilience pipeline; collapses the comment into a single string
|
||||
write at the wire level.
|
||||
|
||||
The OPC UA Part 9 `AlarmConditionState.OnAcknowledge` delegate
|
||||
already validates the session's `AlarmAck` role before dispatching,
|
||||
so the gateway-side ack RPC only sees authenticated, authorised
|
||||
calls.
|
||||
|
||||
## Historian write-back (non-Galaxy alarms)
|
||||
|
||||
Scripted alarms (and any future non-Galaxy `IAlarmSource` like
|
||||
AB CIP ALMD) route to AVEVA Historian via the Wonderware sidecar:
|
||||
|
||||
- `Phase7Composer.ResolveHistorianSink` resolves an
|
||||
`IAlarmHistorianWriter` from either a driver that natively
|
||||
implements it or the DI-registered `WonderwareHistorianClient`
|
||||
(the sidecar IPC client). Driver-provided wins when both are
|
||||
present.
|
||||
- `SqliteStoreAndForwardSink` queues each transition to a local
|
||||
SQLite database and drains in the background via the resolved
|
||||
writer.
|
||||
- Sidecar (PR C.1 + C.2) forwards the events to `aahClientManaged`'s
|
||||
alarm-event write API; the live SDK call site is pinned during
|
||||
PR D.1's deploy-rig validation.
|
||||
|
||||
Galaxy-native alarms with `$Alarm*` extensions reach AVEVA Historian
|
||||
directly via System Platform's `HistorizeToAveva` toggle on the
|
||||
alarm primitive — no involvement from OtOpcUa. This sidecar path is
|
||||
exclusively for non-Galaxy alarm producers.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- Plan: [docs/plans/alarms-over-gateway.md](plans/alarms-over-gateway.md)
|
||||
- v1 archive: [docs/v1/AlarmTracking.md](v1/AlarmTracking.md)
|
||||
- Galaxy driver: [docs/drivers/Galaxy.md](drivers/Galaxy.md)
|
||||
- Phase 7 scripting + alarming: [docs/v2/implementation/phase-7-scripting-and-alarming.md](v2/implementation/phase-7-scripting-and-alarming.md)
|
||||
- Security + ACL: [docs/Security.md](Security.md)
|
||||
+14
-2
@@ -15,7 +15,8 @@ For the driver spec (capability surface, config shape, addressing), see [docs/v2
|
||||
| ITagDiscovery / IReadable / |
|
||||
| IWritable / ISubscribable / |
|
||||
| IRediscoverable / |
|
||||
| IHostConnectivityProbe |
|
||||
| IHostConnectivityProbe / |
|
||||
| IAlarmSource |
|
||||
+-------------------+-------------------+
|
||||
|
|
||||
gRPC (default http://localhost:5120)
|
||||
@@ -33,7 +34,18 @@ For the driver spec (capability surface, config shape, addressing), see [docs/v2
|
||||
+---------------------------------------+
|
||||
```
|
||||
|
||||
History reads + alarm-condition tracking moved server-side in PR 7.2 (`IHistoryRouter`, `AlarmConditionService`). Galaxy no longer implements `IHistoryProvider` or `IAlarmSource` of its own.
|
||||
History reads moved server-side in PR 7.2 (`IHistoryRouter`). Galaxy no longer implements `IHistoryProvider` of its own.
|
||||
|
||||
`IAlarmSource` was retired with PR 7.2 and **restored in PR B.2** of the
|
||||
alarms-over-gateway epic ([docs/plans/alarms-over-gateway.md](../plans/alarms-over-gateway.md)).
|
||||
Alarm transitions arrive on the same gateway `StreamEvents` channel as
|
||||
data-change events under the new `MX_EVENT_FAMILY_ON_ALARM_TRANSITION`
|
||||
family; acknowledgements route through the gateway's
|
||||
`AcknowledgeAlarm` RPC. The previous value-driven sub-attribute path
|
||||
remains as a fallback for Galaxy templates without `$Alarm*`
|
||||
extensions — the server-side `AlarmConditionService` dedups when both
|
||||
paths fire on the same condition. See [docs/AlarmTracking.md](../AlarmTracking.md)
|
||||
for the v2-final architecture.
|
||||
|
||||
## Project Layout
|
||||
|
||||
|
||||
@@ -1,5 +1,63 @@
|
||||
# Plan — alarms over the mxaccessgw gateway
|
||||
|
||||
> **17 of 19 PRs merged. Public contract surface and the lmxopcua /
|
||||
> sidecar consumers are live; four merged PRs ship as scaffolds
|
||||
> pending worker-side wiring.** Status reconciled against the source
|
||||
> tree on 2026-05-01.
|
||||
>
|
||||
> **Functional end-to-end today:** B.1 / B.2 / B.3 / B.4 / B.5
|
||||
> (EventPump branch, GalaxyDriver `IAlarmSource`, DriverNodeManager
|
||||
> ack routing, `WonderwareHistorianClient : IAlarmHistorianWriter`,
|
||||
> docs sweep), C.2 (sidecar wires the alarm-write slot), D.1 script
|
||||
> (`scripts/install/Refresh-Services.ps1`), E.1 – E.7 (proto regen +
|
||||
> .NET / Python / Go / Java / Rust SDK alarm methods + lmxopcua client
|
||||
> surface). The value-driven sub-attribute fallback path keeps Galaxy
|
||||
> alarms functional today.
|
||||
>
|
||||
> **Merged-but-inert scaffolds (gated on worker AlarmClient wiring):**
|
||||
>
|
||||
> - **A.2** — `MxAccessAlarmEventSink.Attach` is a no-op; the COM-side
|
||||
> `aaAlarmManagedClient.AlarmClient` registration / subscription has
|
||||
> not landed yet, so the gateway's
|
||||
> `MX_EVENT_FAMILY_ON_ALARM_TRANSITION` is reserved on the wire but
|
||||
> never emitted.
|
||||
> - **A.3** AcknowledgeAlarm + **A.4** QueryActiveAlarms — public RPC
|
||||
> handlers in `MxAccessGatewayService.cs` route through
|
||||
> `NotWiredAlarmRpcDispatcher` (Ack returns OK with a `worker dispatch
|
||||
> pending dev-rig wiring` diagnostic; Query yields an empty stream).
|
||||
> - **C.1** sidecar — `AahClientManagedAlarmEventWriter` exists and the
|
||||
> IPC slot is wired, but the production backend
|
||||
> `SdkAlarmHistorianWriteBackend.WriteBatchAsync` returns
|
||||
> `RetryPlease` for every event with a placeholder log — the live
|
||||
> `aahClientManaged` SDK call site is pinned during the D.1 dev-rig
|
||||
> smoke. Effect: scripted-alarm transitions queue locally in
|
||||
> `SqliteStoreAndForwardSink` and the drain worker repeatedly retries.
|
||||
>
|
||||
> **Architectural decision RESOLVED 2026-04-30** (recorded in the
|
||||
> mxaccessgw repo at `src/MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs`
|
||||
> xmldoc): the worker hosts `aaAlarmManagedClient.AlarmClient` (x86
|
||||
> .NET Framework 4.8 — same bitness as the existing MxAccess COM
|
||||
> consumer) alongside the COM consumer, sharing the worker's STA +
|
||||
> WM_APP message pump. The discovered API surface
|
||||
> (`RegisterConsumer`, `Subscribe`, `GetStatistics`,
|
||||
> `GetAlarmExtendedRec`, `AlarmAckByGUID`) is documented in that
|
||||
> file's xmldoc. The earlier concern that AVEVA's alarm SDK was
|
||||
> x64-only proved wrong against the deployed assemblies. What remains
|
||||
> is wiring PRs in the worker — session-startup `RegisterConsumer` +
|
||||
> `Subscribe`, an STA WM_APP handler that routes
|
||||
> alarm-changed messages into `EnqueueTransition`, and the worker
|
||||
> command path that calls `AlarmAckByGUID` from a gateway
|
||||
> `AcknowledgeAlarm` RPC.
|
||||
>
|
||||
> **D.1 smoke artifact**
|
||||
> (`docs/plans/artifacts/d1-rollout-YYYY-MM-DD.md`, called for in the
|
||||
> Track D test plan below) not yet captured — gated on the worker
|
||||
> AlarmClient wiring being live on the dev rig so the smoke can
|
||||
> exercise the alarm scenarios end-to-end and pin the
|
||||
> `SdkAlarmHistorianWriteBackend` SDK entry point.
|
||||
>
|
||||
> The remainder of this document is preserved as the design record.
|
||||
|
||||
Coordinated epic across two repos:
|
||||
|
||||
- **`lmxopcua`** (this repo) — `c:\Users\dohertj2\Desktop\lmxopcua\`
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
# Alarm Tracking
|
||||
# Alarm Tracking — v1 archive
|
||||
|
||||
> **Historical record.** This document describes the v1 / pre-PR-7.2
|
||||
> Galaxy alarm path that ran inside `Galaxy.Host`'s STA pump as
|
||||
> `GalaxyAlarmTracker`. PR 7.2 retired the in-process Galaxy stack; the
|
||||
> alarms-over-gateway epic (B.2 / B.3 / E.7) restored Galaxy's
|
||||
> `IAlarmSource` capability against the new gateway-mediated transport.
|
||||
> See [docs/AlarmTracking.md](../AlarmTracking.md) for the v2 final
|
||||
> architecture — that is the document to read for current behaviour.
|
||||
|
||||
Alarm surfacing is an optional driver capability exposed via `IAlarmSource` (`src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs`). Drivers whose backends have an alarm concept implement it — today: Galaxy (MXAccess alarms), FOCAS (CNC alarms), OPC UA Client (A&C events from the upstream server). Modbus / S7 / AB CIP / AB Legacy / TwinCAT do not implement the interface and the feature is simply absent from their subtrees.
|
||||
|
||||
|
||||
@@ -408,6 +408,49 @@ For production:
|
||||
- Per-NodeId credentials in `ClusterNodeCredential` table (per decision #83)
|
||||
- Admin app uses LDAP (no SQL credential at all on the user-facing side)
|
||||
|
||||
## Service Refresh — `Refresh-Services.ps1`
|
||||
|
||||
The deploy host hosts three NSSM-wrapped services (`MxAccessGw`,
|
||||
`OtOpcUaWonderwareHistorian`, `OtOpcUa`) that consume binaries from
|
||||
`C:\publish\`. After landing changes in either repo, refresh the
|
||||
deployed bits with `scripts\install\Refresh-Services.ps1`:
|
||||
|
||||
```powershell
|
||||
# Default invocation (dev rig).
|
||||
& C:\Users\dohertj2\Desktop\lmxopcua\scripts\install\Refresh-Services.ps1
|
||||
|
||||
# Skip the timestamped backup (faster on iterative dev cycles).
|
||||
& Refresh-Services.ps1 -SkipBackup
|
||||
|
||||
# Dry-run — print the actions without doing them.
|
||||
& Refresh-Services.ps1 -WhatIf
|
||||
```
|
||||
|
||||
The script:
|
||||
|
||||
1. Stops services in reverse-dependency order (`OtOpcUa` →
|
||||
`OtOpcUaWonderwareHistorian` → `MxAccessGw`) and force-kills
|
||||
any residual processes.
|
||||
2. Snapshots the existing `C:\publish\mxaccessgw\` and
|
||||
`C:\publish\lmxopcua\` trees to `C:\publish\.backup-<timestamp>\`
|
||||
for rollback (skip with `-SkipBackup`).
|
||||
3. Builds + copies mxaccessgw worker (x86 net48) + server (net10.0)
|
||||
binaries from the sibling repo.
|
||||
4. `dotnet publish`-es the OtOpcUa server + Wonderware historian
|
||||
sidecar from this repo.
|
||||
5. Ensures `OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED=true` is set on
|
||||
the historian service env block (PR C.2 toggle).
|
||||
6. Starts services in forward-dependency order (`MxAccessGw` →
|
||||
`OtOpcUaWonderwareHistorian` → `OtOpcUa`).
|
||||
7. Smoke-verifies — service status, listening ports (5120 / 4840 /
|
||||
4841), recent log tails.
|
||||
|
||||
Functional verification (alarm raise / scripted alarm historian
|
||||
round-trip / sub-attribute fallback) is the operator's next step
|
||||
after the refresh; see
|
||||
[docs/plans/alarms-over-gateway.md](../plans/alarms-over-gateway.md)
|
||||
§Track D for the scenarios.
|
||||
|
||||
## Test Data Seed
|
||||
|
||||
Each environment needs a baseline data set so cross-developer tests are reproducible. Lives in `tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/SeedData/`:
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$RepoRoot = "C:\Users\dohertj2\Desktop\lmxopcua",
|
||||
[string]$GatewayRoot = "C:\Users\dohertj2\Desktop\mxaccessgw",
|
||||
[string]$PublishRoot = "C:\publish",
|
||||
[switch]$SkipBackup,
|
||||
[switch]$WhatIf
|
||||
)
|
||||
|
||||
# PR D.1 — refresh C:\publish + restart services for the alarms-over-gateway
|
||||
# epic. Stops services in reverse-dependency order (OtOpcUa →
|
||||
# OtOpcUaWonderwareHistorian → MxAccessGw), refreshes binaries from the
|
||||
# repos, then starts in forward order. A timestamped backup of the existing
|
||||
# C:\publish trees lands under C:\publish\.backup-YYYY-MM-DD\ unless
|
||||
# -SkipBackup is supplied.
|
||||
#
|
||||
# Designed to run as a single elevated PowerShell session on the deploy host
|
||||
# (the dev rig today; production refresh is a separate runbook).
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Step([string]$Message) {
|
||||
Write-Host ""
|
||||
Write-Host "==> $Message" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function Run([scriptblock]$Block, [string]$Description) {
|
||||
if ($WhatIf) {
|
||||
Write-Host " (skip) $Description" -ForegroundColor DarkYellow
|
||||
return
|
||||
}
|
||||
Write-Host " $Description"
|
||||
& $Block
|
||||
}
|
||||
|
||||
function Test-NssmService([string]$Name) {
|
||||
$svc = Get-Service -Name $Name -ErrorAction SilentlyContinue
|
||||
return $null -ne $svc
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Step 1: Stop in reverse dependency order
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
Step "Stopping services (OtOpcUa → OtOpcUaWonderwareHistorian → MxAccessGw)"
|
||||
|
||||
foreach ($name in @('OtOpcUa', 'OtOpcUaWonderwareHistorian', 'MxAccessGw')) {
|
||||
if (Test-NssmService $name) {
|
||||
Run { nssm stop $name } "stop $name"
|
||||
}
|
||||
else {
|
||||
Write-Host " ($name not installed; skipping)" -ForegroundColor DarkGray
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $WhatIf) {
|
||||
Start-Sleep -Seconds 3
|
||||
Get-Process MxGateway.Server, MxGateway.Worker, OtOpcUa.Server, OtOpcUa.Driver.Historian.Wonderware -ErrorAction SilentlyContinue |
|
||||
ForEach-Object {
|
||||
Write-Host " killing residual process $($_.ProcessName) (PID=$($_.Id))" -ForegroundColor DarkYellow
|
||||
Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Step 2: Backup existing C:\publish trees
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
if (-not $SkipBackup -and (Test-Path $PublishRoot)) {
|
||||
$backupRoot = Join-Path $PublishRoot ".backup-$((Get-Date).ToString('yyyy-MM-dd-HHmmss'))"
|
||||
Step "Backing up $PublishRoot → $backupRoot"
|
||||
|
||||
Run {
|
||||
New-Item -ItemType Directory -Path $backupRoot | Out-Null
|
||||
foreach ($subdir in @('mxaccessgw', 'lmxopcua')) {
|
||||
$src = Join-Path $PublishRoot $subdir
|
||||
if (Test-Path $src) {
|
||||
Copy-Item -Recurse -Path $src -Destination (Join-Path $backupRoot $subdir)
|
||||
}
|
||||
}
|
||||
} "snapshot publish dirs (rollback target)"
|
||||
}
|
||||
else {
|
||||
Write-Host " (backup skipped)" -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Step 3: Refresh mxaccessgw binaries (Track A output)
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
Step "Building + copying mxaccessgw binaries from $GatewayRoot"
|
||||
|
||||
Run {
|
||||
& dotnet build "$GatewayRoot\src\MxGateway.Worker" -c Release | Out-Null
|
||||
& dotnet build "$GatewayRoot\src\MxGateway.Server" -c Release | Out-Null
|
||||
} "dotnet build (Worker x86 net48 + Server net10.0)"
|
||||
|
||||
Run {
|
||||
$serverDest = Join-Path $PublishRoot "mxaccessgw\Server"
|
||||
$workerDest = Join-Path $PublishRoot "mxaccessgw\Worker"
|
||||
if (-not (Test-Path $serverDest)) { New-Item -ItemType Directory -Path $serverDest -Force | Out-Null }
|
||||
if (-not (Test-Path $workerDest)) { New-Item -ItemType Directory -Path $workerDest -Force | Out-Null }
|
||||
Copy-Item -Recurse -Force "$GatewayRoot\src\MxGateway.Server\bin\Release\net10.0\*" $serverDest
|
||||
Copy-Item -Recurse -Force "$GatewayRoot\src\MxGateway.Worker\bin\x86\Release\net48\*" $workerDest
|
||||
} "copy gateway server + worker outputs"
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Step 4: Refresh OtOpcUa + Wonderware historian sidecar
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
Step "Publishing OtOpcUa server + Wonderware historian sidecar from $RepoRoot"
|
||||
|
||||
Run {
|
||||
& dotnet publish "$RepoRoot\src\ZB.MOM.WW.OtOpcUa.Server" `
|
||||
-c Release -o (Join-Path $PublishRoot "lmxopcua") | Out-Null
|
||||
& dotnet publish "$RepoRoot\src\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware" `
|
||||
-c Release -o (Join-Path $PublishRoot "lmxopcua\WonderwareHistorian") | Out-Null
|
||||
} "dotnet publish (Server + sidecar)"
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Step 5: Service env block — ensure OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED
|
||||
# is set on the Wonderware historian service (PR C.2 toggle).
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
if (Test-NssmService 'OtOpcUaWonderwareHistorian') {
|
||||
Step "Ensuring OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED is set on the historian service"
|
||||
|
||||
Run {
|
||||
$existing = nssm get OtOpcUaWonderwareHistorian AppEnvironmentExtra
|
||||
if ($existing -notmatch 'OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED') {
|
||||
$combined = $existing + "`r`nOTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED=true"
|
||||
nssm set OtOpcUaWonderwareHistorian AppEnvironmentExtra $combined | Out-Null
|
||||
Write-Host " appended OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED=true" -ForegroundColor DarkGreen
|
||||
}
|
||||
else {
|
||||
Write-Host " already present; leaving service env block untouched"
|
||||
}
|
||||
} "patch service env block"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Step 6: Start in forward dependency order
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
Step "Starting services (MxAccessGw → OtOpcUaWonderwareHistorian → OtOpcUa)"
|
||||
|
||||
foreach ($pair in @(
|
||||
@{ Name = 'MxAccessGw'; Wait = 4 },
|
||||
@{ Name = 'OtOpcUaWonderwareHistorian'; Wait = 4 },
|
||||
@{ Name = 'OtOpcUa'; Wait = 8 }
|
||||
)) {
|
||||
$name = $pair.Name
|
||||
if (Test-NssmService $name) {
|
||||
Run { nssm start $name } "start $name"
|
||||
if (-not $WhatIf) { Start-Sleep -Seconds $pair.Wait }
|
||||
}
|
||||
else {
|
||||
Write-Host " ($name not installed; skipping)" -ForegroundColor DarkGray
|
||||
}
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Step 7: Smoke verification
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
Step "Smoke verification"
|
||||
|
||||
if (-not $WhatIf) {
|
||||
foreach ($name in @('MxAccessGw', 'OtOpcUaWonderwareHistorian', 'OtOpcUa')) {
|
||||
if (Test-NssmService $name) {
|
||||
$status = (Get-Service $name).Status
|
||||
$color = if ($status -eq 'Running') { 'Green' } else { 'Red' }
|
||||
Write-Host " $name = $status" -ForegroundColor $color
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($port in @(5120, 4840, 4841)) {
|
||||
$listening = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue
|
||||
$color = if ($listening) { 'Green' } else { 'DarkYellow' }
|
||||
Write-Host " TCP $port listening = $($null -ne $listening)" -ForegroundColor $color
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " Recent log tails:" -ForegroundColor DarkCyan
|
||||
$tails = @(
|
||||
"$PublishRoot\lmxopcua\logs\otopcua-*.log",
|
||||
"$PublishRoot\mxaccessgw\stdout.log",
|
||||
"$env:ProgramData\OtOpcUa\historian-wonderware-*.log"
|
||||
)
|
||||
foreach ($pattern in $tails) {
|
||||
$latest = Get-ChildItem -Path $pattern -ErrorAction SilentlyContinue |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
if ($null -ne $latest) {
|
||||
Write-Host ""
|
||||
Write-Host " --- $($latest.FullName) (last 10 lines) ---" -ForegroundColor DarkGray
|
||||
Get-Content $latest.FullName -Tail 10 | ForEach-Object { Write-Host " $_" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Refresh complete." -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "Next: run the functional verification scenarios from"
|
||||
Write-Host " docs\plans\alarms-over-gateway.md §Track D §6 'Functional verification'"
|
||||
Write-Host " - Galaxy-native alarm raise"
|
||||
Write-Host " - Scripted alarm → AVEVA Historian round-trip"
|
||||
Write-Host " - Sub-attribute fallback path with IAlarmSource disabled"
|
||||
@@ -15,7 +15,10 @@ public sealed class AlarmEventArgs : EventArgs
|
||||
bool ackedState,
|
||||
DateTime time,
|
||||
byte[]? eventId = null,
|
||||
string? conditionNodeId = null)
|
||||
string? conditionNodeId = null,
|
||||
string? operatorComment = null,
|
||||
DateTime? originalRaiseTimestampUtc = null,
|
||||
string? alarmCategory = null)
|
||||
{
|
||||
SourceName = sourceName;
|
||||
ConditionName = conditionName;
|
||||
@@ -27,6 +30,9 @@ public sealed class AlarmEventArgs : EventArgs
|
||||
Time = time;
|
||||
EventId = eventId;
|
||||
ConditionNodeId = conditionNodeId;
|
||||
OperatorComment = operatorComment;
|
||||
OriginalRaiseTimestampUtc = originalRaiseTimestampUtc;
|
||||
AlarmCategory = alarmCategory;
|
||||
}
|
||||
|
||||
/// <summary>The name of the source object that raised the alarm.</summary>
|
||||
@@ -58,4 +64,25 @@ public sealed class AlarmEventArgs : EventArgs
|
||||
|
||||
/// <summary>The NodeId of the condition instance (SourceNode), used for acknowledgment.</summary>
|
||||
public string? ConditionNodeId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// PR E.7 — Operator-supplied comment recorded by the upstream alarm system on
|
||||
/// Acknowledge transitions. Null on raise / clear, or when the upstream path
|
||||
/// can't surface the comment (sub-attribute fallback path collapses comments
|
||||
/// into a single string write).
|
||||
/// </summary>
|
||||
public string? OperatorComment { get; }
|
||||
|
||||
/// <summary>
|
||||
/// PR E.7 — When the alarm originally entered the active state. Preserved
|
||||
/// across Acknowledge transitions so OPC UA Part 9 conditions keep the
|
||||
/// original raise time. Null when the upstream path doesn't surface it.
|
||||
/// </summary>
|
||||
public DateTime? OriginalRaiseTimestampUtc { get; }
|
||||
|
||||
/// <summary>
|
||||
/// PR E.7 — Upstream alarm taxonomy bucket (e.g. <c>Process</c> /
|
||||
/// <c>Safety</c> / <c>Diagnostics</c>). Null when not surfaced.
|
||||
/// </summary>
|
||||
public string? AlarmCategory { get; }
|
||||
}
|
||||
@@ -41,6 +41,30 @@ public sealed record AlarmAcknowledgeRequest(
|
||||
string? Comment);
|
||||
|
||||
/// <summary>Event payload for <see cref="IAlarmSource.OnAlarmEvent"/>.</summary>
|
||||
/// <param name="SubscriptionHandle">Subscription this event belongs to.</param>
|
||||
/// <param name="SourceNodeId">Driver-side identifier for the alarm source.</param>
|
||||
/// <param name="ConditionId">Stable id correlating raise / ack / clear of the same condition.</param>
|
||||
/// <param name="AlarmType">Driver-defined alarm type name (e.g. AnalogLimitAlarm.HiHi).</param>
|
||||
/// <param name="Message">Human-readable alarm description.</param>
|
||||
/// <param name="Severity">Four-bucket severity ladder.</param>
|
||||
/// <param name="SourceTimestampUtc">When this transition occurred.</param>
|
||||
/// <param name="OperatorComment">
|
||||
/// Operator-supplied comment recorded by the upstream alarm system on Acknowledge
|
||||
/// transitions. Null on raise / clear, or when the upstream path can't surface
|
||||
/// the comment (the Galaxy sub-attribute fallback path collapses comments into a
|
||||
/// single string write — null on that path; the driver-native gateway path
|
||||
/// populates this).
|
||||
/// </param>
|
||||
/// <param name="OriginalRaiseTimestampUtc">
|
||||
/// When the alarm originally entered the active state. Preserved across
|
||||
/// Acknowledge transitions so OPC UA Part 9 conditions keep the original raise
|
||||
/// time in <c>Time</c>. Null when the upstream path doesn't surface it.
|
||||
/// </param>
|
||||
/// <param name="AlarmCategory">
|
||||
/// Upstream alarm taxonomy bucket (e.g. <c>Process</c> / <c>Safety</c> /
|
||||
/// <c>Diagnostics</c>). Maps to OPC UA <c>ConditionClassName</c> downstream when
|
||||
/// a class mapping is configured. Null when the upstream path doesn't carry it.
|
||||
/// </param>
|
||||
public sealed record AlarmEventArgs(
|
||||
IAlarmSubscriptionHandle SubscriptionHandle,
|
||||
string SourceNodeId,
|
||||
@@ -48,7 +72,10 @@ public sealed record AlarmEventArgs(
|
||||
string AlarmType,
|
||||
string Message,
|
||||
AlarmSeverity Severity,
|
||||
DateTime SourceTimestampUtc);
|
||||
DateTime SourceTimestampUtc,
|
||||
string? OperatorComment = null,
|
||||
DateTime? OriginalRaiseTimestampUtc = null,
|
||||
string? AlarmCategory = null);
|
||||
|
||||
/// <summary>Mirrors the <c>NodePermissions</c> alarm-severity enum in <c>docs/v2/acl-design.md</c>.</summary>
|
||||
public enum AlarmSeverity { Low, Medium, High, Critical }
|
||||
|
||||
@@ -26,7 +26,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||
/// "GalaxyMxGateway" so both paths can be live simultaneously during parity testing.
|
||||
/// </remarks>
|
||||
public sealed class GalaxyDriver
|
||||
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IDisposable
|
||||
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IAlarmSource, IDisposable
|
||||
{
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly GalaxyDriverOptions _options;
|
||||
@@ -63,6 +63,16 @@ public sealed class GalaxyDriver
|
||||
private EventPump? _eventPump;
|
||||
private readonly Lock _pumpLock = new();
|
||||
|
||||
// PR B.2 — IAlarmSource implementation. Production-side acks route through
|
||||
// GatewayGalaxyAlarmAcknowledger which calls MxGatewayClient.AcknowledgeAlarmAsync
|
||||
// (PR E.2 SDK). Tests inject IGalaxyAlarmAcknowledger via the internal ctor to
|
||||
// exercise the wiring without a running gateway. The alarm event stream is
|
||||
// delivered by EventPump.OnAlarmTransition (PR B.1) — this driver is the
|
||||
// consumer that bridges it onto IAlarmSource.OnAlarmEvent.
|
||||
private IGalaxyAlarmAcknowledger? _alarmAcknowledger;
|
||||
private readonly Lock _alarmHandlersLock = new();
|
||||
private readonly HashSet<GalaxyAlarmSubscriptionHandle> _alarmSubscriptions = new();
|
||||
|
||||
// PR 4.W — production runtime owned by InitializeAsync. The driver builds these
|
||||
// when it opens a real gw session; tests bypass them by injecting seams via the
|
||||
// internal ctor.
|
||||
@@ -99,12 +109,16 @@ public sealed class GalaxyDriver
|
||||
/// <summary>Fires when a host transitions Running ↔ Stopped (PR 4.7 HostStatusAggregator).</summary>
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||
|
||||
public GalaxyDriver(
|
||||
string driverInstanceId,
|
||||
GalaxyDriverOptions options,
|
||||
ILogger<GalaxyDriver>? logger = null)
|
||||
: this(driverInstanceId, options,
|
||||
hierarchySource: null, dataReader: null, dataWriter: null, subscriber: null, logger)
|
||||
hierarchySource: null, dataReader: null, dataWriter: null, subscriber: null,
|
||||
alarmAcknowledger: null, logger)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -121,6 +135,7 @@ public sealed class GalaxyDriver
|
||||
IGalaxyDataReader? dataReader = null,
|
||||
IGalaxyDataWriter? dataWriter = null,
|
||||
IGalaxySubscriber? subscriber = null,
|
||||
IGalaxyAlarmAcknowledger? alarmAcknowledger = null,
|
||||
ILogger<GalaxyDriver>? logger = null)
|
||||
{
|
||||
_driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId)
|
||||
@@ -132,6 +147,7 @@ public sealed class GalaxyDriver
|
||||
_dataReader = dataReader;
|
||||
_dataWriter = dataWriter;
|
||||
_subscriber = subscriber;
|
||||
_alarmAcknowledger = alarmAcknowledger;
|
||||
|
||||
// Forward the aggregator's transitions through IHostConnectivityProbe.
|
||||
_hostStatuses.OnHostStatusChanged += (_, args) => OnHostStatusChanged?.Invoke(this, args);
|
||||
@@ -213,6 +229,9 @@ public sealed class GalaxyDriver
|
||||
_probeWatcher = new PerPlatformProbeWatcher(
|
||||
_subscriber, _hostStatuses, _logger,
|
||||
bufferedUpdateIntervalMs: _options.MxAccess.PublishingIntervalMs);
|
||||
|
||||
// PR B.2 — wire the alarm acknowledger to the live gateway client.
|
||||
_alarmAcknowledger ??= new GatewayGalaxyAlarmAcknowledger(_ownedMxClient, _ownedMxSession, _logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -705,11 +724,135 @@ public sealed class GalaxyDriver
|
||||
channelCapacity: _options.MxAccess.EventPumpChannelCapacity,
|
||||
clientName: _options.MxAccess.ClientName);
|
||||
_eventPump.OnDataChange += OnPumpDataChange;
|
||||
_eventPump.OnAlarmTransition += OnPumpAlarmTransition;
|
||||
_eventPump.Start();
|
||||
return _eventPump;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== IAlarmSource (PR B.2) =====
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(sourceNodeIds);
|
||||
|
||||
// The driver doesn't multiplex alarm subscriptions per source-node-id today —
|
||||
// alarm events arrive on the same gateway StreamEvents channel as data-change
|
||||
// events once the gateway emits the new family (PRs A.2 + A.3). The
|
||||
// subscription handle is a sentinel the server uses for symmetric Unsubscribe;
|
||||
// every active handle receives every alarm transition, and the server filters
|
||||
// by source node before raising Part 9 conditions. Same shape AbCip uses.
|
||||
EnsureEventPumpStarted();
|
||||
var handle = new GalaxyAlarmSubscriptionHandle(Guid.NewGuid().ToString("N"));
|
||||
lock (_alarmHandlersLock)
|
||||
{
|
||||
_alarmSubscriptions.Add(handle);
|
||||
}
|
||||
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(handle);
|
||||
if (handle is not GalaxyAlarmSubscriptionHandle gash)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Subscription handle was not issued by this driver (expected GalaxyAlarmSubscriptionHandle, got {handle.GetType().Name}).",
|
||||
nameof(handle));
|
||||
}
|
||||
lock (_alarmHandlersLock)
|
||||
{
|
||||
_alarmSubscriptions.Remove(gash);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(acknowledgements);
|
||||
if (acknowledgements.Count == 0) return;
|
||||
|
||||
if (_alarmAcknowledger is null)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"GalaxyDriver.AcknowledgeAsync requires GatewayGalaxyAlarmAcknowledger wired against a connected " +
|
||||
"GalaxyMxSession (PR B.2). InitializeAsync must run before alarm acknowledgements can flow.");
|
||||
}
|
||||
|
||||
// Acks are issued one-by-one — the gateway RPC accepts a single alarm
|
||||
// reference per call. AlarmConditionState's per-condition Acknowledge in the
|
||||
// server-side ACL layer is the natural rate-limit, so issuing in series here
|
||||
// keeps the operator-comment ordering deterministic without bursting the
|
||||
// worker's STA queue.
|
||||
foreach (var ack in acknowledgements)
|
||||
{
|
||||
// ConditionId carries the alarm full reference for the Galaxy driver —
|
||||
// SourceNodeId is the OPC UA browse path, which the gateway can't address.
|
||||
// The server-side condition state pairs them through AlarmConditionService.
|
||||
var alarmFullReference = !string.IsNullOrEmpty(ack.ConditionId)
|
||||
? ack.ConditionId
|
||||
: ack.SourceNodeId;
|
||||
await _alarmAcknowledger.AcknowledgeAsync(
|
||||
alarmFullReference,
|
||||
ack.Comment ?? string.Empty,
|
||||
operatorUser: string.Empty, // server-side ACL fills this from the OPC UA session
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Receives <see cref="GalaxyAlarmTransition"/> events from the EventPump and
|
||||
/// reshapes them into <see cref="AlarmEventArgs"/> for OPC UA-side consumers.
|
||||
/// Fires <see cref="OnAlarmEvent"/> only when at least one alarm subscription is
|
||||
/// active so a server that hasn't called <see cref="SubscribeAlarmsAsync"/> yet
|
||||
/// doesn't surface untracked transitions.
|
||||
/// </summary>
|
||||
private void OnPumpAlarmTransition(object? sender, GalaxyAlarmTransition transition)
|
||||
{
|
||||
GalaxyAlarmSubscriptionHandle? handle;
|
||||
lock (_alarmHandlersLock)
|
||||
{
|
||||
// Pick any active subscription handle as the "owner" of the event. The
|
||||
// server-side state machine doesn't multiplex by handle today; if multiple
|
||||
// alarm subscriptions are active we still only fire the event once and
|
||||
// the AlarmConditionService dispatches per-source-node downstream.
|
||||
handle = _alarmSubscriptions.Count > 0
|
||||
? _alarmSubscriptions.First()
|
||||
: null;
|
||||
}
|
||||
if (handle is null) return;
|
||||
|
||||
var args = new AlarmEventArgs(
|
||||
SubscriptionHandle: handle,
|
||||
SourceNodeId: transition.SourceObjectReference,
|
||||
ConditionId: transition.AlarmFullReference,
|
||||
AlarmType: transition.AlarmTypeName,
|
||||
Message: transition.Description,
|
||||
Severity: transition.SeverityBucket,
|
||||
SourceTimestampUtc: transition.TransitionTimestampUtc,
|
||||
OperatorComment: string.IsNullOrEmpty(transition.OperatorComment) ? null : transition.OperatorComment,
|
||||
OriginalRaiseTimestampUtc: transition.OriginalRaiseTimestampUtc,
|
||||
AlarmCategory: string.IsNullOrEmpty(transition.Category) ? null : transition.Category);
|
||||
try
|
||||
{
|
||||
OnAlarmEvent?.Invoke(this, args);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"GalaxyDriver OnAlarmEvent handler threw for {AlarmRef} — continuing.",
|
||||
transition.AlarmFullReference);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forwards every fan-out event to the public <see cref="OnDataChange"/> for
|
||||
/// ISubscribable consumers, AND routes ScanState changes to the per-platform
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Driver-side handle returned by <see cref="GalaxyDriver.SubscribeAlarmsAsync"/>.
|
||||
/// The driver doesn't multiplex alarm transitions per handle — every active handle
|
||||
/// observes the gateway's alarm-event stream — but the handle is needed for
|
||||
/// symmetric Unsubscribe and for the server-side AlarmConditionService to
|
||||
/// correlate transitions with the originating subscription.
|
||||
/// </summary>
|
||||
internal sealed class GalaxyAlarmSubscriptionHandle : IAlarmSubscriptionHandle
|
||||
{
|
||||
public GalaxyAlarmSubscriptionHandle(string diagnosticId)
|
||||
{
|
||||
DiagnosticId = diagnosticId;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DiagnosticId { get; }
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MxGateway.Client;
|
||||
using MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Production <see cref="IGalaxyAlarmAcknowledger"/> backed by the
|
||||
/// <c>MxGatewayClient.AcknowledgeAlarmAsync</c> RPC (PR E.2). Maps the
|
||||
/// reply's protocol status into a thrown exception when the gateway
|
||||
/// reports a non-OK condition; native MxStatus failures inside the reply
|
||||
/// surface as a logged warning so operator workflows aren't blocked by a
|
||||
/// transient MxAccess hiccup.
|
||||
/// </summary>
|
||||
internal sealed class GatewayGalaxyAlarmAcknowledger : IGalaxyAlarmAcknowledger
|
||||
{
|
||||
private readonly MxGatewayClient _client;
|
||||
private readonly GalaxyMxSession _session;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public GatewayGalaxyAlarmAcknowledger(
|
||||
MxGatewayClient client,
|
||||
GalaxyMxSession session,
|
||||
ILogger logger)
|
||||
{
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task AcknowledgeAsync(
|
||||
string alarmFullReference,
|
||||
string comment,
|
||||
string operatorUser,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(alarmFullReference);
|
||||
|
||||
var session = _session.Session
|
||||
?? throw new InvalidOperationException(
|
||||
"GatewayGalaxyAlarmAcknowledger requires a connected GalaxyMxSession; underlying gateway session is null.");
|
||||
var sessionId = session.SessionId;
|
||||
|
||||
var reply = await _client.AcknowledgeAlarmAsync(
|
||||
new AcknowledgeAlarmRequest
|
||||
{
|
||||
SessionId = sessionId,
|
||||
ClientCorrelationId = Guid.NewGuid().ToString("N"),
|
||||
AlarmFullReference = alarmFullReference,
|
||||
Comment = comment ?? string.Empty,
|
||||
OperatorUser = operatorUser ?? string.Empty,
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (reply.Status is { Success: 0 } status)
|
||||
{
|
||||
// Native MxAccess rejected the ack — log but don't throw. Treat as a
|
||||
// best-effort operator workflow; the operator can retry via the OPC UA
|
||||
// session if necessary.
|
||||
_logger.LogWarning(
|
||||
"Galaxy AcknowledgeAlarm for {AlarmRef} returned MxStatus failure: category={Category} detail={Detail} text={Text}",
|
||||
alarmFullReference, status.Category, status.Detail, status.DiagnosticText);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Test seam for the gateway-side Acknowledge call. Production wraps the
|
||||
/// <c>MxGatewayClient.AcknowledgeAlarmAsync</c> RPC; tests substitute a fake
|
||||
/// so <see cref="GalaxyDriver.AcknowledgeAsync"/> can be exercised without a
|
||||
/// running gateway.
|
||||
/// </summary>
|
||||
internal interface IGalaxyAlarmAcknowledger
|
||||
{
|
||||
/// <summary>
|
||||
/// Forward a single alarm acknowledgement to the gateway. The gateway
|
||||
/// translates this to an MxAccess Acknowledge call against the worker's
|
||||
/// session and returns the native MxStatus on the reply.
|
||||
/// </summary>
|
||||
/// <param name="alarmFullReference">
|
||||
/// Fully-qualified alarm reference (e.g. <c>"Tank01.Level.HiHi"</c>).
|
||||
/// </param>
|
||||
/// <param name="comment">Operator-supplied comment forwarded to MxAccess.</param>
|
||||
/// <param name="operatorUser">
|
||||
/// Operator principal performing the acknowledgement. Resolved from the
|
||||
/// OPC UA session by the server-side ACL layer before reaching the driver.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Cancels the gateway RPC.</param>
|
||||
Task AcknowledgeAsync(
|
||||
string alarmFullReference,
|
||||
string comment,
|
||||
string operatorUser,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -221,6 +221,49 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR B.3 — preferred <see cref="IAlarmAcknowledger"/> for drivers that implement
|
||||
/// <see cref="IAlarmSource"/> (today: Galaxy via the gateway-side AcknowledgeAlarm
|
||||
/// RPC). Routes the operator comment through the driver's native ack API, which
|
||||
/// preserves operator-comment fidelity end-to-end (the value-driven sub-attribute
|
||||
/// fallback collapses the comment into a single string write).
|
||||
/// </summary>
|
||||
private sealed class DriverAlarmSourceAcknowledger(
|
||||
IAlarmSource alarmSource,
|
||||
string conditionId,
|
||||
ZB.MOM.WW.OtOpcUa.Core.Resilience.AlarmSurfaceInvoker alarmInvoker) : IAlarmAcknowledger
|
||||
{
|
||||
public async Task<bool> WriteAckMessageAsync(
|
||||
string ackMsgWriteRef, string comment, CancellationToken cancellationToken)
|
||||
{
|
||||
// ackMsgWriteRef is unused on this path — the driver's IAlarmSource.AcknowledgeAsync
|
||||
// routes the ack against the alarm condition itself, not against the
|
||||
// sub-attribute. ConditionId carries the alarm full reference; SourceNodeId
|
||||
// is left empty since the gateway only addresses by full reference.
|
||||
// _ = alarmSource keeps the analyzer-required reference visible without an
|
||||
// unwrapped call — the actual ack runs through the AlarmSurfaceInvoker which
|
||||
// wires the AlarmAcknowledge resilience pipeline (no-retry per decision #143).
|
||||
_ = alarmSource;
|
||||
try
|
||||
{
|
||||
await alarmInvoker.AcknowledgeAsync(
|
||||
new[]
|
||||
{
|
||||
new AlarmAcknowledgeRequest(
|
||||
SourceNodeId: string.Empty,
|
||||
ConditionId: conditionId,
|
||||
Comment: comment ?? string.Empty),
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detach from the alarm service before the base disposes. The service is shared across
|
||||
/// drivers, so leaking the handler keeps a dead DriverNodeManager pinned in memory and
|
||||
@@ -787,8 +830,23 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
if (_owner._alarmService is not null && !string.IsNullOrEmpty(info.InAlarmRef))
|
||||
{
|
||||
_owner._conditionSinks[FullReference] = sink;
|
||||
var acker = new DriverWritableAcknowledger(
|
||||
// PR B.3 — prefer IAlarmSource.AcknowledgeAsync (driver-native path)
|
||||
// when the driver supports it. Galaxy implements this since PR B.2;
|
||||
// for drivers without IAlarmSource the value-driven sub-attribute
|
||||
// fallback (DriverWritableAcknowledger) preserves the existing
|
||||
// behaviour.
|
||||
IAlarmAcknowledger acker;
|
||||
if (_owner._driver is IAlarmSource alarmSource)
|
||||
{
|
||||
var alarmInvoker = new ZB.MOM.WW.OtOpcUa.Core.Resilience.AlarmSurfaceInvoker(
|
||||
_owner._invoker, alarmSource, _owner._driver.DriverInstanceId);
|
||||
acker = new DriverAlarmSourceAcknowledger(alarmSource, FullReference, alarmInvoker);
|
||||
}
|
||||
else
|
||||
{
|
||||
acker = new DriverWritableAcknowledger(
|
||||
_owner._writable, _owner._invoker, _owner._driver.DriverInstanceId);
|
||||
}
|
||||
_owner._alarmService.Track(FullReference, info, acker);
|
||||
}
|
||||
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
using System.Threading.Channels;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PR E.7 — pins that the GalaxyDriver populates the extended AlarmEventArgs
|
||||
/// fields (OperatorComment, OriginalRaiseTimestampUtc, AlarmCategory) when the
|
||||
/// gateway emits a transition with the rich payload, and leaves them null on
|
||||
/// events that don't carry them.
|
||||
/// </summary>
|
||||
public sealed class GalaxyDriverAlarmEventArgsExtensionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Acknowledge_transition_with_full_payload_populates_extended_fields()
|
||||
{
|
||||
var subscriber = new ManualSubscriber();
|
||||
using var driver = NewDriver(subscriber);
|
||||
|
||||
await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
|
||||
var observed = new List<AlarmEventArgs>();
|
||||
driver.OnAlarmEvent += (_, args) => observed.Add(args);
|
||||
await driver.SubscribeAsync(["Tank01.Level"], TimeSpan.Zero, CancellationToken.None);
|
||||
|
||||
var raise = new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var ack = raise.AddSeconds(45);
|
||||
await subscriber.EmitAlarmAsync(new MxEvent
|
||||
{
|
||||
Family = MxEventFamily.OnAlarmTransition,
|
||||
OnAlarmTransition = new OnAlarmTransitionEvent
|
||||
{
|
||||
AlarmFullReference = "Tank01.Level.HiHi",
|
||||
SourceObjectReference = "Tank01",
|
||||
AlarmTypeName = "AnalogLimitAlarm.HiHi",
|
||||
TransitionKind = AlarmTransitionKind.Acknowledge,
|
||||
Severity = 750,
|
||||
OriginalRaiseTimestamp = Timestamp.FromDateTime(raise),
|
||||
TransitionTimestamp = Timestamp.FromDateTime(ack),
|
||||
OperatorUser = "alice",
|
||||
OperatorComment = "investigating",
|
||||
Category = "Process",
|
||||
Description = "Tank 01 high-high level",
|
||||
},
|
||||
});
|
||||
|
||||
for (var i = 0; i < 20 && observed.Count == 0; i++)
|
||||
{
|
||||
await Task.Delay(50);
|
||||
}
|
||||
observed.ShouldHaveSingleItem();
|
||||
observed[0].OperatorComment.ShouldBe("investigating");
|
||||
observed[0].OriginalRaiseTimestampUtc.ShouldBe(raise);
|
||||
observed[0].AlarmCategory.ShouldBe("Process");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Raise_transition_without_optional_fields_leaves_them_null()
|
||||
{
|
||||
var subscriber = new ManualSubscriber();
|
||||
using var driver = NewDriver(subscriber);
|
||||
|
||||
await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
|
||||
var observed = new List<AlarmEventArgs>();
|
||||
driver.OnAlarmEvent += (_, args) => observed.Add(args);
|
||||
await driver.SubscribeAsync(["Tank01.Level"], TimeSpan.Zero, CancellationToken.None);
|
||||
|
||||
await subscriber.EmitAlarmAsync(new MxEvent
|
||||
{
|
||||
Family = MxEventFamily.OnAlarmTransition,
|
||||
OnAlarmTransition = new OnAlarmTransitionEvent
|
||||
{
|
||||
AlarmFullReference = "Tank01.Level.HiHi",
|
||||
AlarmTypeName = "AnalogLimitAlarm.HiHi",
|
||||
TransitionKind = AlarmTransitionKind.Raise,
|
||||
Severity = 750,
|
||||
TransitionTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
|
||||
},
|
||||
});
|
||||
|
||||
for (var i = 0; i < 20 && observed.Count == 0; i++)
|
||||
{
|
||||
await Task.Delay(50);
|
||||
}
|
||||
observed.ShouldHaveSingleItem();
|
||||
observed[0].OperatorComment.ShouldBeNull();
|
||||
observed[0].OriginalRaiseTimestampUtc.ShouldBeNull();
|
||||
observed[0].AlarmCategory.ShouldBeNull();
|
||||
}
|
||||
|
||||
private static GalaxyDriver NewDriver(ManualSubscriber subscriber)
|
||||
{
|
||||
var options = new GalaxyDriverOptions(
|
||||
new GalaxyGatewayOptions("http://localhost:5000", "literal-api-key"),
|
||||
new GalaxyMxAccessOptions("AlarmExtensionTest"),
|
||||
new GalaxyRepositoryOptions(),
|
||||
new GalaxyReconnectOptions());
|
||||
return new GalaxyDriver(
|
||||
driverInstanceId: "drv-1",
|
||||
options: options,
|
||||
hierarchySource: null,
|
||||
dataReader: null,
|
||||
dataWriter: null,
|
||||
subscriber: subscriber,
|
||||
alarmAcknowledger: null);
|
||||
}
|
||||
|
||||
private sealed class ManualSubscriber : IGalaxySubscriber
|
||||
{
|
||||
private readonly Channel<MxEvent> _stream =
|
||||
Channel.CreateUnbounded<MxEvent>(new UnboundedChannelOptions { SingleReader = true });
|
||||
|
||||
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
|
||||
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<SubscribeResult>();
|
||||
var nextHandle = 100;
|
||||
foreach (var r in fullReferences)
|
||||
{
|
||||
results.Add(new SubscribeResult { TagAddress = r, ItemHandle = nextHandle++, WasSuccessful = true });
|
||||
}
|
||||
return Task.FromResult<IReadOnlyList<SubscribeResult>>(results);
|
||||
}
|
||||
|
||||
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
|
||||
=> _stream.Reader.ReadAllAsync(cancellationToken);
|
||||
|
||||
public ValueTask EmitAlarmAsync(MxEvent ev) => _stream.Writer.WriteAsync(ev);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
using System.Threading.Channels;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PR B.2 — pins GalaxyDriver's IAlarmSource implementation. The driver bridges
|
||||
/// EventPump.OnAlarmTransition (PR B.1) onto IAlarmSource.OnAlarmEvent and
|
||||
/// forwards Acknowledge through IGalaxyAlarmAcknowledger (production:
|
||||
/// GatewayGalaxyAlarmAcknowledger calling the gateway's AcknowledgeAlarm RPC
|
||||
/// from PR E.2).
|
||||
/// </summary>
|
||||
public sealed class GalaxyDriverAlarmSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SubscribeAlarmsAsync_returns_handle_and_event_fires_after_pump_alarm()
|
||||
{
|
||||
var subscriber = new ManualSubscriber();
|
||||
var ack = new RecordingAcknowledger();
|
||||
using var driver = NewDriver(subscriber, ack);
|
||||
|
||||
// Subscribe so OnAlarmEvent has a registered handle to fire under.
|
||||
var handle = await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
|
||||
handle.ShouldNotBeNull();
|
||||
|
||||
var observed = new List<AlarmEventArgs>();
|
||||
driver.OnAlarmEvent += (_, args) => observed.Add(args);
|
||||
|
||||
// SubscribeAsync to start the EventPump (alarm wiring is lazy on first sub).
|
||||
await driver.SubscribeAsync(["Tank01.Level"], TimeSpan.Zero, CancellationToken.None);
|
||||
|
||||
await subscriber.EmitAlarmAsync(new MxEvent
|
||||
{
|
||||
Family = MxEventFamily.OnAlarmTransition,
|
||||
OnAlarmTransition = new OnAlarmTransitionEvent
|
||||
{
|
||||
AlarmFullReference = "Tank01.Level.HiHi",
|
||||
SourceObjectReference = "Tank01",
|
||||
AlarmTypeName = "AnalogLimitAlarm.HiHi",
|
||||
TransitionKind = AlarmTransitionKind.Raise,
|
||||
Severity = 750,
|
||||
TransitionTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
|
||||
Description = "Tank 01 high-high level",
|
||||
},
|
||||
});
|
||||
|
||||
// Drain pump events.
|
||||
for (var i = 0; i < 20 && observed.Count == 0; i++)
|
||||
{
|
||||
await Task.Delay(50);
|
||||
}
|
||||
|
||||
observed.ShouldHaveSingleItem();
|
||||
observed[0].ConditionId.ShouldBe("Tank01.Level.HiHi");
|
||||
observed[0].SourceNodeId.ShouldBe("Tank01");
|
||||
observed[0].AlarmType.ShouldBe("AnalogLimitAlarm.HiHi");
|
||||
observed[0].Severity.ShouldBe(AlarmSeverity.Critical);
|
||||
observed[0].SubscriptionHandle.ShouldBe(handle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnAlarmEvent_does_not_fire_when_no_subscription_active()
|
||||
{
|
||||
var subscriber = new ManualSubscriber();
|
||||
var ack = new RecordingAcknowledger();
|
||||
using var driver = NewDriver(subscriber, ack);
|
||||
|
||||
var observed = new List<AlarmEventArgs>();
|
||||
driver.OnAlarmEvent += (_, args) => observed.Add(args);
|
||||
|
||||
// Start the pump via a data subscription so alarm events flow but no alarm
|
||||
// subscription is registered → OnAlarmEvent is suppressed.
|
||||
await driver.SubscribeAsync(["Tank01.Level"], TimeSpan.Zero, CancellationToken.None);
|
||||
await subscriber.EmitAlarmAsync(new MxEvent
|
||||
{
|
||||
Family = MxEventFamily.OnAlarmTransition,
|
||||
OnAlarmTransition = new OnAlarmTransitionEvent
|
||||
{
|
||||
AlarmFullReference = "Tank01.Level.HiHi",
|
||||
TransitionKind = AlarmTransitionKind.Raise,
|
||||
Severity = 600,
|
||||
TransitionTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
|
||||
},
|
||||
});
|
||||
await Task.Delay(150);
|
||||
|
||||
observed.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsubscribeAlarmsAsync_stops_event_flow()
|
||||
{
|
||||
var subscriber = new ManualSubscriber();
|
||||
var ack = new RecordingAcknowledger();
|
||||
using var driver = NewDriver(subscriber, ack);
|
||||
|
||||
var handle = await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
|
||||
var observed = new List<AlarmEventArgs>();
|
||||
driver.OnAlarmEvent += (_, args) => observed.Add(args);
|
||||
await driver.SubscribeAsync(["Tank01.Level"], TimeSpan.Zero, CancellationToken.None);
|
||||
|
||||
await driver.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||||
|
||||
await subscriber.EmitAlarmAsync(new MxEvent
|
||||
{
|
||||
Family = MxEventFamily.OnAlarmTransition,
|
||||
OnAlarmTransition = new OnAlarmTransitionEvent
|
||||
{
|
||||
AlarmFullReference = "Tank01.Level.HiHi",
|
||||
TransitionKind = AlarmTransitionKind.Raise,
|
||||
Severity = 600,
|
||||
TransitionTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
|
||||
},
|
||||
});
|
||||
await Task.Delay(150);
|
||||
|
||||
observed.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsubscribeAlarmsAsync_throws_for_foreign_handle()
|
||||
{
|
||||
var subscriber = new ManualSubscriber();
|
||||
var ack = new RecordingAcknowledger();
|
||||
using var driver = NewDriver(subscriber, ack);
|
||||
|
||||
var foreignHandle = new ForeignAlarmHandle();
|
||||
await Should.ThrowAsync<ArgumentException>(() =>
|
||||
driver.UnsubscribeAlarmsAsync(foreignHandle, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_routes_each_request_to_the_acknowledger()
|
||||
{
|
||||
var subscriber = new ManualSubscriber();
|
||||
var ack = new RecordingAcknowledger();
|
||||
using var driver = NewDriver(subscriber, ack);
|
||||
|
||||
var requests = new[]
|
||||
{
|
||||
new AlarmAcknowledgeRequest("Tank01", "Tank01.Level.HiHi", "shift handover"),
|
||||
new AlarmAcknowledgeRequest("Tank02", "Tank02.Level.HiHi", "investigating"),
|
||||
};
|
||||
|
||||
await driver.AcknowledgeAsync(requests, CancellationToken.None);
|
||||
|
||||
ack.Calls.Count.ShouldBe(2);
|
||||
ack.Calls[0].AlarmRef.ShouldBe("Tank01.Level.HiHi");
|
||||
ack.Calls[0].Comment.ShouldBe("shift handover");
|
||||
ack.Calls[1].AlarmRef.ShouldBe("Tank02.Level.HiHi");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_falls_back_to_SourceNodeId_when_ConditionId_empty()
|
||||
{
|
||||
var subscriber = new ManualSubscriber();
|
||||
var ack = new RecordingAcknowledger();
|
||||
using var driver = NewDriver(subscriber, ack);
|
||||
|
||||
await driver.AcknowledgeAsync(
|
||||
[new AlarmAcknowledgeRequest("Tank01.Level.HiHi", string.Empty, null)],
|
||||
CancellationToken.None);
|
||||
|
||||
ack.Calls[0].AlarmRef.ShouldBe("Tank01.Level.HiHi");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_throws_NotSupported_without_acknowledger()
|
||||
{
|
||||
var subscriber = new ManualSubscriber();
|
||||
using var driver = NewDriver(subscriber, alarmAcknowledger: null);
|
||||
|
||||
await Should.ThrowAsync<NotSupportedException>(() =>
|
||||
driver.AcknowledgeAsync(
|
||||
[new AlarmAcknowledgeRequest("Tank01", "Tank01.Level.HiHi", null)],
|
||||
CancellationToken.None));
|
||||
}
|
||||
|
||||
private static GalaxyDriver NewDriver(
|
||||
ManualSubscriber subscriber, IGalaxyAlarmAcknowledger? alarmAcknowledger)
|
||||
{
|
||||
var options = new GalaxyDriverOptions(
|
||||
new GalaxyGatewayOptions("http://localhost:5000", "literal-api-key"),
|
||||
new GalaxyMxAccessOptions("AlarmSourceTest"),
|
||||
new GalaxyRepositoryOptions(),
|
||||
new GalaxyReconnectOptions());
|
||||
return new GalaxyDriver(
|
||||
driverInstanceId: "drv-1",
|
||||
options: options,
|
||||
hierarchySource: null,
|
||||
dataReader: null,
|
||||
dataWriter: null,
|
||||
subscriber: subscriber,
|
||||
alarmAcknowledger: alarmAcknowledger);
|
||||
}
|
||||
|
||||
private sealed class RecordingAcknowledger : IGalaxyAlarmAcknowledger
|
||||
{
|
||||
public List<(string AlarmRef, string Comment, string Operator)> Calls { get; } = [];
|
||||
|
||||
public Task AcknowledgeAsync(string alarmFullReference, string comment, string operatorUser, CancellationToken cancellationToken)
|
||||
{
|
||||
Calls.Add((alarmFullReference, comment, operatorUser));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ForeignAlarmHandle : IAlarmSubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => "foreign";
|
||||
}
|
||||
|
||||
private sealed class ManualSubscriber : IGalaxySubscriber
|
||||
{
|
||||
private readonly Channel<MxEvent> _stream =
|
||||
Channel.CreateUnbounded<MxEvent>(new UnboundedChannelOptions { SingleReader = true });
|
||||
|
||||
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
|
||||
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<SubscribeResult>();
|
||||
var nextHandle = 100;
|
||||
foreach (var r in fullReferences)
|
||||
{
|
||||
results.Add(new SubscribeResult { TagAddress = r, ItemHandle = nextHandle++, WasSuccessful = true });
|
||||
}
|
||||
return Task.FromResult<IReadOnlyList<SubscribeResult>>(results);
|
||||
}
|
||||
|
||||
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
|
||||
=> _stream.Reader.ReadAllAsync(cancellationToken);
|
||||
|
||||
public ValueTask EmitAlarmAsync(MxEvent ev) => _stream.Writer.WriteAsync(ev);
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Alarms;
|
||||
|
||||
/// <summary>
|
||||
/// PR B.3 — pins the routing decision DriverNodeManager makes when registering
|
||||
/// an AlarmConditionState: drivers that implement <see cref="IAlarmSource"/>
|
||||
/// get an acknowledger that calls AcknowledgeAsync (driver-native path); drivers
|
||||
/// that don't fall back to the IWritable sub-attribute write.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DriverAlarmSourceAcknowledgerRoutingTests
|
||||
{
|
||||
[Fact]
|
||||
public void Driver_with_IAlarmSource_is_recognized()
|
||||
{
|
||||
IDriver driver = new FakeDriverWithAlarmSource("drv-1");
|
||||
(driver is IAlarmSource).ShouldBeTrue(
|
||||
"fakes that participate in the routing-test fixture must report IAlarmSource");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_without_IAlarmSource_falls_to_writable_path()
|
||||
{
|
||||
IDriver driver = new FakeDriverNoAlarmSource("drv-2");
|
||||
(driver is IAlarmSource).ShouldBeFalse(
|
||||
"drivers without IAlarmSource take the legacy DriverWritableAcknowledger path");
|
||||
}
|
||||
|
||||
private sealed class FakeDriverWithAlarmSource(string id) : IDriver, IAlarmSource
|
||||
{
|
||||
public string DriverInstanceId { get; } = id;
|
||||
public string DriverType => "FakeAlarmSource";
|
||||
public Task InitializeAsync(string c, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string c, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IAlarmSubscriptionHandle>(new FakeHandle("h"));
|
||||
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||
private void NoUnusedWarning() => OnAlarmEvent?.Invoke(this, null!);
|
||||
}
|
||||
|
||||
private sealed class FakeDriverNoAlarmSource(string id) : IDriver
|
||||
{
|
||||
public string DriverInstanceId { get; } = id;
|
||||
public string DriverType => "FakeNoAlarmSource";
|
||||
public Task InitializeAsync(string c, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string c, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class FakeHandle(string id) : IAlarmSubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId { get; } = id;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user