diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx
index 6231325..e578810 100644
--- a/ZB.MOM.WW.OtOpcUa.slnx
+++ b/ZB.MOM.WW.OtOpcUa.slnx
@@ -9,9 +9,6 @@
-
-
-
@@ -46,12 +43,6 @@
-
-
-
-
-
-
diff --git a/scripts/compliance/phase-7-compliance.ps1 b/scripts/compliance/phase-7-compliance.ps1
index e0b0847..e78fe30 100644
--- a/scripts/compliance/phase-7-compliance.ps1
+++ b/scripts/compliance/phase-7-compliance.ps1
@@ -73,13 +73,13 @@ Assert-TextFound "ScriptedAlarmSource implements IAlarmSource" "class ScriptedAl
Assert-TextFound "IAlarmStateStore abstraction + in-memory default" "class InMemoryAlarmStateStore" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/IAlarmStateStore.cs")
Write-Host ""
-Write-Host "Stream D - Core.AlarmHistorian (SQLite store-and-forward + Galaxy.Host IPC contracts)"
+Write-Host "Stream D - Core.AlarmHistorian (SQLite store-and-forward; alarm-event sidecar IPC moved to Driver.Historian.Wonderware.Client in PR 3.4)"
Assert-FileExists "Core.AlarmHistorian project" "src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"
Assert-TextFound "SqliteStoreAndForwardSink backoff ladder (1s..60s cap)" "BackoffLadder" @("src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs")
Assert-TextFound "Default 1M row capacity + 30-day dead-letter retention (plan decision #21)" "DefaultDeadLetterRetention" @("src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs")
Assert-TextFound "Per-event outcomes (Ack/RetryPlease/PermanentFail)" "HistorianWriteOutcome" @("src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/IAlarmHistorianSink.cs")
-Assert-TextFound "Galaxy.Host IPC contract HistorianAlarmEventRequest" "class HistorianAlarmEventRequest" @("src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/HistorianAlarms.cs")
-Assert-TextFound "Historian connectivity status notification" "HistorianConnectivityStatusNotification" @("src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/HistorianAlarms.cs")
+# Galaxy.Shared pipe-IPC contracts retired in PR 7.2 alongside the rest of the legacy
+# Galaxy projects. Wonderware sidecar contracts live in Driver.Historian.Wonderware.Client.
Write-Host ""
Write-Host "Stream E - Config DB schema"
diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md
index 316f04b..96cf909 100644
--- a/scripts/e2e/README.md
+++ b/scripts/e2e/README.md
@@ -63,7 +63,9 @@ live driver. The factory-wiring block that originally gated stages
Live-boot verification:
- **Galaxy** — 7/7 stages (read / write / subscribe / alarms / history)
- against a real Galaxy + `OtOpcUaGalaxyHost` on this dev box.
+ against a real Galaxy via the in-process `GalaxyDriver` →
+ `mxaccessgw` (gRPC). PR 7.2 retired the legacy `OtOpcUaGalaxyHost`
+ out-of-process driver path.
- **AB CIP, S7** — 5/5 stages each under task #220 against the
`ab_server` + `python-snap7` fixtures.
- **AB Legacy** — 5/5 stages under task #222 against `ab_server` SLC500
@@ -155,7 +157,7 @@ section to skip it.
| Modbus | — | **PASS** (pymodbus fixture) |
| AB CIP | — | **PASS** (ab_server fixture) |
| AB Legacy | — | **PASS** (ab_server SLC500/MicroLogix/PLC-5 profiles; `/1,0` cip-path required for the Docker fixture) |
-| Galaxy | — | **PASS** (requires OtOpcUaGalaxyHost + a live Galaxy; 7 stages including alarms + history) |
+| Galaxy | — | **PASS** (requires mxaccessgw running + a live Galaxy; 7 stages including alarms + history; PR 7.2 retired the legacy OtOpcUaGalaxyHost path) |
| S7 | — | **PASS** (python-snap7 fixture) |
| FOCAS | `FOCAS_TRUST_WIRE=1` | **SKIP** (no public simulator — task #222 lab rig) |
| TwinCAT | `TWINCAT_TRUST_WIRE=1` | **SKIP** by default; features **validated** against the TCBSD VM fixture — set the env var to run |
diff --git a/scripts/e2e/e2e-config.sample.json b/scripts/e2e/e2e-config.sample.json
index c8dbc64..05377b4 100644
--- a/scripts/e2e/e2e-config.sample.json
+++ b/scripts/e2e/e2e-config.sample.json
@@ -50,7 +50,7 @@
},
"galaxy": {
- "$comment": "Galaxy (MXAccess) driver. Has no per-driver CLI — all stages go through otopcua-cli against the published NodeIds. Seven stages: probe / source read / virtual-tag bridge / subscribe-sees-change / reverse write / alarm fires / history read. PR 7.1 default-flipped backend to GalaxyMxGateway (in-process .NET 10 driver over mxaccessgw gRPC at http://localhost:5120 by default — override via the DriverInstance row's DriverConfig). Pre-flip rigs running the legacy 'Galaxy' DriverType still need OtOpcUaGalaxyHost running + seed-phase-7-smoke.sql applied with a real Galaxy attribute substituted into dbo.Tag.TagConfig.",
+ "$comment": "Galaxy (MXAccess) driver. Has no per-driver CLI — all stages go through otopcua-cli against the published NodeIds. Seven stages: probe / source read / virtual-tag bridge / subscribe-sees-change / reverse write / alarm fires / history read. The driver is now the in-process GalaxyDriver (DriverType = 'GalaxyMxGateway') talking gRPC to a separately-installed mxaccessgw at http://localhost:5120 by default — override via the DriverInstance row's DriverConfig. PR 7.2 retired the legacy 'Galaxy' DriverType + OtOpcUaGalaxyHost service.",
"sourceNodeId": "ns=2;s=p7-smoke-tag-source",
"virtualNodeId": "ns=2;s=p7-smoke-vt-derived",
"alarmNodeId": "ns=2;s=p7-smoke-al-overtemp",
diff --git a/scripts/e2e/test-galaxy.ps1 b/scripts/e2e/test-galaxy.ps1
deleted file mode 100644
index dc0e3e4..0000000
--- a/scripts/e2e/test-galaxy.ps1
+++ /dev/null
@@ -1,298 +0,0 @@
-#Requires -Version 7.0
-<#
-.SYNOPSIS
- End-to-end CLI test for the Galaxy (MXAccess) driver — read, write, subscribe,
- alarms, and history through a running OtOpcUa server.
-
-.DESCRIPTION
- Unlike the other e2e scripts there is no `otopcua-galaxy-cli` — the Galaxy
- driver proxy lives in-process with the server + talks to `OtOpcUaGalaxyHost`
- over a named pipe (MXAccess is 32-bit COM, can't ship in the .NET 10 process).
- Every stage therefore goes through `otopcua-cli` against the published OPC UA
- address space.
-
- Seven stages:
-
- 1. Probe — otopcua-cli connect + read the source NodeId; confirms
- the whole Galaxy.Host → Proxy → server → client chain is
- up
- 2. Source read — otopcua-cli read returns a Good value for the source
- attribute; proves IReadable.ReadAsync is dispatching
- through the IPC bridge
- 3. Virtual-tag bridge — `otopcua-cli read` on the VirtualTag NodeId; confirms
- the Phase 7 CachedTagUpstreamSource is bridging the
- driver-sourced input into the scripting engine
- 4. Subscribe-sees-change — subscribe to the source NodeId in the background;
- Galaxy pushes a data-change event within N seconds
- (Galaxy's underlying attribute must be actively
- changing — production Galaxies typically have
- scan-driven updates; for idle galaxies, widen
- -ChangeWaitSec or drive the write stage below first)
- 5. Reverse bridge — `otopcua-cli write` to a writable Galaxy attribute;
- read it back. Gracefully becomes INFO-only if the
- attribute's Galaxy-side AccessLevel forbids writes
- (BadUserAccessDenied / BadNotWritable)
- 6. Alarm fires — subscribe to the scripted-alarm Condition NodeId,
- drive the source tag above its threshold, confirm an
- Active alarm event surfaces. Exercises the Part 9
- alarm-condition propagation path
- 7. History read — historyread on the source tag over the last hour;
- confirms Aveva Historian → IHistoryProvider dispatch
- returns samples
-
- The Phase 7 seed (`scripts/smoke/seed-phase-7-smoke.sql`) already plants the
- right shape — one Galaxy DriverInstance, one source Tag, one VirtualTag
- (source × 2), one ScriptedAlarm (source > 50). Substitute the real Galaxy
- attribute FullName into `dbo.Tag.TagConfig` before running.
-
-.PARAMETER OpcUaUrl
- OtOpcUa server endpoint. Default opc.tcp://localhost:4840.
-
-.PARAMETER SourceNodeId
- NodeId of the driver-sourced Galaxy tag (numeric, writable preferred). NodeIds
- are path-based per OPC UA Part 3 §5.2.2 — the default matches the Phase 7 seed
- walking `p7-smoke-galaxy` (DriverInstanceId) → `lab-floor` → `galaxy-line` →
- `reactor-1` → `Source` (Tag.Name).
-
-.PARAMETER VirtualNodeId
- NodeId of the VirtualTag that computes MachineStatus = (Source > 0) (Phase 7
- scripting). Same path-based scheme, ending in the VirtualTag.Name
- (`MachineStatus`). The tag is historized so the write/subscribe exercise
- doubles as a historian-sink check.
-
-.PARAMETER AlarmNodeId
- NodeId of the scripted-alarm Condition (fires when Source > 50). Same
- path-based scheme, ending in ScriptedAlarm.Name (`OverTemp`).
-
-.PARAMETER AlarmTriggerValue
- Value written to -SourceNodeId to push it over the alarm threshold.
- Default 75 (well above the seeded 50-threshold).
-
-.PARAMETER ChangeWaitSec
- Seconds the subscribe-sees-change stage waits for a natural data change.
- Default 10. Idle galaxies may need this extended or the stage will fail
- with "subscribe did not observe...".
-
-.PARAMETER AlarmWaitSec
- Seconds the alarm-fires stage waits after triggering the write. Default 10.
-
-.PARAMETER HistoryLookbackSec
- Seconds back from now to query history. Default 3600 (1 h).
-
-.EXAMPLE
- # Against the default Phase-7 smoke seed + live Galaxy + OtOpcUa server
- ./scripts/e2e/test-galaxy.ps1
-
-.EXAMPLE
- # Custom NodeIds from a non-smoke cluster
- ./scripts/e2e/test-galaxy.ps1 `
- -SourceNodeId "ns=2;s=Reactor1.Temperature" `
- -VirtualNodeId "ns=2;s=Reactor1.TempDoubled" `
- -AlarmNodeId "ns=2;s=Reactor1.OverTemp" `
- -AlarmTriggerValue 120
-#>
-
-param(
- [string]$OpcUaUrl = "opc.tcp://localhost:4840",
- [string]$SourceNodeId = "ns=2;s=p7-smoke-galaxy/lab-floor/galaxy-line/reactor-1/Source",
- [string]$VirtualNodeId = "ns=2;s=p7-smoke-galaxy/lab-floor/galaxy-line/reactor-1/MachineStatus",
- [string]$AlarmNodeId = "ns=2;s=p7-smoke-galaxy/lab-floor/galaxy-line/reactor-1/OverTemp",
- [string]$AlarmTriggerValue = "75",
- [int]$ChangeWaitSec = 10,
- [int]$AlarmWaitSec = 10,
- [int]$HistoryLookbackSec = 3600,
- # The default Phase 7 seed uses a Galaxy attribute with
- # security_classification=Operate. Anonymous OPC UA sessions are denied writes
- # against Operate-classified tags (PR 26 / docs/Security.md). Supply an LDAP
- # user with WriteOperate to exercise the reverse-bridge stage — e.g.
- # `-Username writeop -Password writeop123` against the dev-box GLAuth.
- [string]$Username = "",
- [string]$Password = ""
-)
-
-$ErrorActionPreference = "Stop"
-. "$PSScriptRoot/_common.ps1"
-
-$opcUaCli = Get-CliInvocation `
- -ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
- -ExeName "otopcua-cli"
-
-# Auth-extension helper — appends `-U / -P` to the CLI args when credentials
-# were supplied. Stays empty for anonymous runs so the default smoke path
-# doesn't require an LDAP round-trip.
-$authArgs = @()
-if ($Username) { $authArgs += @("-U", $Username) }
-if ($Password) { $authArgs += @("-P", $Password) }
-
-$results = @()
-
-# ---------------------------------------------------------------------------
-# Stage 1 — Probe. The probe is an otopcua-cli read against the source NodeId;
-# success implies Galaxy.Host is up + the pipe ACL lets the server connect +
-# the Proxy is tracking the tag + the server published it.
-# ---------------------------------------------------------------------------
-
-Write-Header "Probe"
-$probe = Invoke-Cli -Cli $opcUaCli -Args (@("read", "-u", $OpcUaUrl, "-n", $SourceNodeId) + $authArgs)
-if ($probe.ExitCode -eq 0 -and $probe.Output -match "Status:\s+0x00000000") {
- Write-Pass "source NodeId readable (Galaxy pipe → proxy → server → client chain up)"
- $results += @{ Passed = $true }
-} else {
- Write-Fail "probe read failed (exit=$($probe.ExitCode))"
- Write-Host $probe.Output
- $results += @{ Passed = $false; Reason = "probe failed" }
-}
-
-# ---------------------------------------------------------------------------
-# Stage 2 — Source read. Captures the current value for the later virtual-tag
-# comparison + confirms read dispatch works end-to-end. Failure here without a
-# stage-1 failure would be unusual — probe already reads.
-# ---------------------------------------------------------------------------
-
-Write-Header "Source read"
-$sourceRead = Invoke-Cli -Cli $opcUaCli -Args (@("read", "-u", $OpcUaUrl, "-n", $SourceNodeId) + $authArgs)
-$sourceValue = $null
-if ($sourceRead.ExitCode -eq 0 -and $sourceRead.Output -match "Value:\s+([^\r\n]+)") {
- $sourceValue = $Matches[1].Trim()
- Write-Pass "source value = $sourceValue"
- $results += @{ Passed = $true }
-} else {
- Write-Fail "source read failed"
- Write-Host $sourceRead.Output
- $results += @{ Passed = $false; Reason = "source read failed" }
-}
-
-# ---------------------------------------------------------------------------
-# Stage 3 — Virtual-tag bridge. Reads the Phase 7 VirtualTag (source × 2). Not
-# strictly driver-specific, but exercises the CachedTagUpstreamSource bridge
-# (the seam most likely to silently stop working after a Galaxy-side change).
-# Skip if the VirtualNodeId param is empty (non-Phase-7 clusters).
-# ---------------------------------------------------------------------------
-
-if ([string]::IsNullOrEmpty($VirtualNodeId)) {
- Write-Header "Virtual-tag bridge"
- Write-Skip "VirtualNodeId not supplied — skipping Phase 7 bridge check"
-} else {
- Write-Header "Virtual-tag bridge"
- $vtRead = Invoke-Cli -Cli $opcUaCli -Args (@("read", "-u", $OpcUaUrl, "-n", $VirtualNodeId) + $authArgs)
- if ($vtRead.ExitCode -eq 0 -and $vtRead.Output -match "Value:\s+([^\r\n]+)") {
- $vtValue = $Matches[1].Trim()
- Write-Pass "virtual-tag value = $vtValue (source was $sourceValue)"
- $results += @{ Passed = $true }
- } else {
- Write-Fail "virtual-tag read failed"
- Write-Host $vtRead.Output
- $results += @{ Passed = $false; Reason = "virtual-tag read failed" }
- }
-}
-
-# ---------------------------------------------------------------------------
-# Stage 4 — Subscribe-sees-change. otopcua-cli subscribe in the background;
-# wait N seconds for Galaxy to push any data-change event on the source node.
-# This is optimistic — if the Galaxy attribute is idle, widen -ChangeWaitSec.
-# ---------------------------------------------------------------------------
-
-Write-Header "Subscribe sees change"
-$stdout = New-TemporaryFile
-$stderr = New-TemporaryFile
-$subArgs = @($opcUaCli.PrefixArgs) + @(
- "subscribe", "-u", $OpcUaUrl, "-n", $SourceNodeId,
- "-i", "500", "--duration", "$ChangeWaitSec") + $authArgs
-$subProc = Start-Process -FilePath $opcUaCli.File `
- -ArgumentList $subArgs -NoNewWindow -PassThru `
- -RedirectStandardOutput $stdout.FullName `
- -RedirectStandardError $stderr.FullName
-Write-Info "subscription started (pid $($subProc.Id)) for ${ChangeWaitSec}s"
-$subProc.WaitForExit(($ChangeWaitSec + 5) * 1000) | Out-Null
-if (-not $subProc.HasExited) { Stop-Process -Id $subProc.Id -Force }
-$subOut = (Get-Content $stdout.FullName -Raw) + (Get-Content $stderr.FullName -Raw)
-Remove-Item $stdout.FullName, $stderr.FullName -ErrorAction SilentlyContinue
-
-# Any `=` followed by `(Good)` line after the initial subscribe-confirmation
-# indicates at least one data-change tick arrived. The `@(...)` forces an array
-# so `.Count` works on the 0-match + single-match cases that Set-StrictMode
-# -Version 3.0 otherwise flags as `property 'Count' cannot be found`.
-$changeLines = @(($subOut -split "`n") | Where-Object { $_ -match "=\s+.*\(Good\)" })
-if ($changeLines.Count -gt 0) {
- Write-Pass "$($changeLines.Count) data-change events observed"
- $results += @{ Passed = $true }
-} else {
- Write-Fail "no data-change events in ${ChangeWaitSec}s — Galaxy attribute may be idle; rerun with -ChangeWaitSec larger, or trigger a change first"
- Write-Host $subOut
- $results += @{ Passed = $false; Reason = "no data-change" }
-}
-
-# ---------------------------------------------------------------------------
-# Stage 5 — Reverse bridge (OPC UA write → Galaxy). Galaxy attributes with
-# AccessLevel > FreeAccess often reject anonymous writes; record as INFO when
-# that's the case rather than failing the whole script.
-# ---------------------------------------------------------------------------
-
-Write-Header "Reverse bridge (OPC UA write)"
-$writeValue = [int]$AlarmTriggerValue # reuse the alarm trigger value — two stages for one write
-$w = Invoke-Cli -Cli $opcUaCli -Args (@(
- "write", "-u", $OpcUaUrl, "-n", $SourceNodeId, "-v", "$writeValue") + $authArgs)
-if ($w.ExitCode -ne 0) {
- # Connection/protocol failure — still a test failure.
- Write-Fail "write CLI exit=$($w.ExitCode)"
- Write-Host $w.Output
- $results += @{ Passed = $false; Reason = "write failed" }
-} elseif ($w.Output -match "Write failed:\s*0x801F0000") {
- Write-Info "BadUserAccessDenied — attribute's Galaxy-side ACL blocks writes for this session. Not a bug; grant WriteOperate or run against a writable attribute."
- $results += @{ Passed = $true; Reason = "acl-expected" }
-} elseif ($w.Output -match "Write failed:\s*0x80390000|BadNotWritable") {
- Write-Info "BadNotWritable — attribute is read-only at the Galaxy layer (status attributes, @-prefixed meta, etc)."
- $results += @{ Passed = $true; Reason = "readonly-expected" }
-} elseif ($w.Output -match "Write successful") {
- # Read back — Galaxy poll interval + MXAccess advise may need a second or two to settle.
- Start-Sleep -Seconds 2
- $r = Invoke-Cli -Cli $opcUaCli -Args (@("read", "-u", $OpcUaUrl, "-n", $SourceNodeId) + $authArgs)
- if ($r.Output -match "Value:\s+$([Regex]::Escape("$writeValue"))\b") {
- Write-Pass "write propagated — source reads back $writeValue"
- $results += @{ Passed = $true }
- } else {
- Write-Fail "write reported success but read-back did not reflect $writeValue"
- Write-Host $r.Output
- $results += @{ Passed = $false; Reason = "write-readback mismatch" }
- }
-} else {
- Write-Fail "unexpected write response"
- Write-Host $w.Output
- $results += @{ Passed = $false; Reason = "unexpected write response" }
-}
-
-# ---------------------------------------------------------------------------
-# Stage 6 — Alarm fires. Uses the helper from _common.ps1. If stage 5 already
-# wrote the trigger value the alarm may already be active; that's fine — the
-# Part 9 ConditionRefresh in the alarms CLI replays the current state so the
-# subscribe window still captures the Active event.
-# ---------------------------------------------------------------------------
-
-if ([string]::IsNullOrEmpty($AlarmNodeId)) {
- Write-Header "Alarm fires on threshold"
- Write-Skip "AlarmNodeId not supplied — skipping alarm check"
-} else {
- $results += Test-AlarmFiresOnThreshold `
- -OpcUaCli $opcUaCli `
- -OpcUaUrl $OpcUaUrl `
- -AlarmNodeId $AlarmNodeId `
- -InputNodeId $SourceNodeId `
- -TriggerValue $AlarmTriggerValue `
- -DurationSec $AlarmWaitSec
-}
-
-# ---------------------------------------------------------------------------
-# Stage 7 — History read. historyread against the source tag over the last N
-# seconds. Failure modes the skip pattern catches: tag not historized in the
-# Galaxy attribute's historization profile, or the lookback window misses the
-# sample cadence.
-# ---------------------------------------------------------------------------
-
-$results += Test-HistoryHasSamples `
- -OpcUaCli $opcUaCli `
- -OpcUaUrl $OpcUaUrl `
- -NodeId $SourceNodeId `
- -LookbackSec $HistoryLookbackSec
-
-Write-Summary -Title "Galaxy e2e" -Results $results
-if ($results | Where-Object { -not $_.Passed }) { exit 1 }
diff --git a/scripts/install/Install-Services.ps1 b/scripts/install/Install-Services.ps1
index d0bceca..d411ba0 100644
--- a/scripts/install/Install-Services.ps1
+++ b/scripts/install/Install-Services.ps1
@@ -1,39 +1,52 @@
<#
.SYNOPSIS
- Registers the two v2 Windows services on a node: OtOpcUa (main server, net10) and
- OtOpcUaGalaxyHost (out-of-process Galaxy COM host, net48 x86).
+ Registers the v2 Windows services on a node: OtOpcUa (main server, net10) and
+ optionally OtOpcUaWonderwareHistorian (Wonderware historian sidecar).
.DESCRIPTION
- Phase 2 Stream D.2 — replaces the v1 single-service install (TopShelf-based OtOpcUa.Host).
- Installs both services with the correct service-account SID + per-process shared secret
- provisioning per `driver-stability.md §"IPC Security"`. Galaxy.Host depends on OtOpcUa
- (Galaxy.Host must be reachable when OtOpcUa starts; service dependency wiring + retry
- handled by OtOpcUa.Server NodeBootstrap).
+ PR 7.2 retired the legacy out-of-process OtOpcUaGalaxyHost service alongside the
+ GalaxyProxyDriver / GalaxyHost / GalaxyShared projects. Galaxy access now flows
+ through the in-process GalaxyDriver talking gRPC to a separately-installed
+ mxaccessgw. The mxaccessgw server runs out of its own repo
+ (`c:\Users\dohertj2\Desktop\mxaccessgw\`) — see
+ `docs/v2/Galaxy.ParityRig.md` for the gw setup recipe.
.PARAMETER InstallRoot
Where the binaries live (typically C:\Program Files\OtOpcUa).
.PARAMETER ServiceAccount
- Service account SID or DOMAIN\name. Both services run under this account; the
- Galaxy.Host pipe ACL only allows this SID to connect (decision #76).
+ Service account SID or DOMAIN\name. The OtOpcUa service runs under this account.
-.PARAMETER GalaxySharedSecret
- Per-process secret passed to Galaxy.Host via env var. Generated freshly per install.
+.PARAMETER InstallWonderwareHistorian
+ Gate the OtOpcUaWonderwareHistorian sidecar install. Off by default; set when
+ the deployment uses the Wonderware historian for history reads + alarm-event
+ persistence.
-.PARAMETER ZbConnection
- Galaxy ZB SQL connection string (passed to Galaxy.Host via env var).
+.PARAMETER HistorianSharedSecret
+ Per-process secret passed to the Historian sidecar via env var. Generated
+ freshly per install when not supplied.
.EXAMPLE
.\Install-Services.ps1 -InstallRoot 'C:\Program Files\OtOpcUa' -ServiceAccount 'OTOPCUA\svc-otopcua'
+
+.EXAMPLE
+ .\Install-Services.ps1 -InstallRoot 'C:\Program Files\OtOpcUa' -ServiceAccount 'OTOPCUA\svc-otopcua' `
+ -InstallWonderwareHistorian
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string]$InstallRoot,
[Parameter(Mandatory)] [string]$ServiceAccount,
- [string]$GalaxySharedSecret,
- [string]$ZbConnection = 'Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;',
- [string]$GalaxyClientName = 'OtOpcUa-Galaxy.Host',
- [string]$GalaxyPipeName = 'OtOpcUaGalaxy'
+
+ # PR 3.W — Wonderware historian sidecar. Optional; gates the
+ # OtOpcUaWonderwareHistorian service. Secret + pipe defaults match the server's
+ # Historian:Wonderware appsettings block.
+ [switch]$InstallWonderwareHistorian,
+ [string]$HistorianSharedSecret,
+ [string]$HistorianPipeName = 'OtOpcUaWonderwareHistorian',
+ [string]$HistorianServer = 'localhost',
+ [int]$HistorianPort = 32568,
+ [string[]]$AvevaServiceDependencies = @('NmxSvc', 'aaBootstrap', 'aaGR')
)
$ErrorActionPreference = 'Stop'
@@ -42,17 +55,18 @@ if (-not (Test-Path "$InstallRoot\OtOpcUa.Server.exe")) {
Write-Error "OtOpcUa.Server.exe not found at $InstallRoot — copy the publish output first"
exit 1
}
-if (-not (Test-Path "$InstallRoot\Galaxy\OtOpcUa.Driver.Galaxy.Host.exe")) {
- Write-Error "OtOpcUa.Driver.Galaxy.Host.exe not found at $InstallRoot\Galaxy — copy the publish output first"
- exit 1
-}
-# Generate a fresh shared secret per install if not supplied. Stored in DPAPI-protected file
-# rather than the registry so the service account can read it but other local users cannot.
-if (-not $GalaxySharedSecret) {
+# Generate fresh shared secrets per install if not supplied.
+function New-SharedSecret {
$bytes = New-Object byte[] 32
[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
- $GalaxySharedSecret = [Convert]::ToBase64String($bytes)
+ return [Convert]::ToBase64String($bytes)
+}
+if ($InstallWonderwareHistorian -and -not $HistorianSharedSecret) { $HistorianSharedSecret = New-SharedSecret }
+
+if ($InstallWonderwareHistorian -and -not (Test-Path "$InstallRoot\WonderwareHistorian\OtOpcUa.Driver.Historian.Wonderware.exe")) {
+ Write-Error "OtOpcUa.Driver.Historian.Wonderware.exe not found at $InstallRoot\WonderwareHistorian — copy the publish output first"
+ exit 1
}
# Resolve the SID — the IPC ACL needs the SID, not the down-level name.
@@ -62,41 +76,67 @@ $sid = if ($ServiceAccount.StartsWith('S-1-')) {
(New-Object System.Security.Principal.NTAccount $ServiceAccount).Translate([System.Security.Principal.SecurityIdentifier]).Value
}
-# --- Install OtOpcUaGalaxyHost first (OtOpcUa starts after, depends on it being up).
-$galaxyEnv = @(
- "OTOPCUA_GALAXY_PIPE=$GalaxyPipeName"
- "OTOPCUA_ALLOWED_SID=$sid"
- "OTOPCUA_GALAXY_SECRET=$GalaxySharedSecret"
- "OTOPCUA_GALAXY_BACKEND=mxaccess"
- "OTOPCUA_GALAXY_ZB_CONN=$ZbConnection"
- "OTOPCUA_GALAXY_CLIENT_NAME=$GalaxyClientName"
-) -join "`0"
-$galaxyEnv += "`0`0"
+# --- Install OtOpcUaWonderwareHistorian (PR 3.W) — separate sidecar that exposes the
+# Wonderware Historian SDK via a named-pipe protocol consumed by the .NET 10 server.
+# Optional: only installed when -InstallWonderwareHistorian is supplied. Depends on the
+# hard AVEVA services that host the historian SDK runtime path.
+$historianDepend = $null
+if ($InstallWonderwareHistorian) {
+ $historianEnv = @(
+ "OTOPCUA_HISTORIAN_PIPE=$HistorianPipeName"
+ "OTOPCUA_ALLOWED_SID=$sid"
+ "OTOPCUA_HISTORIAN_SECRET=$HistorianSharedSecret"
+ "OTOPCUA_HISTORIAN_ENABLED=true"
+ "OTOPCUA_HISTORIAN_SERVER=$HistorianServer"
+ "OTOPCUA_HISTORIAN_PORT=$HistorianPort"
+ ) -join "`0"
+ $historianEnv += "`0`0"
-Write-Host "Installing OtOpcUaGalaxyHost..."
-& sc.exe create OtOpcUaGalaxyHost binPath= "`"$InstallRoot\Galaxy\OtOpcUa.Driver.Galaxy.Host.exe`"" `
- DisplayName= 'OtOpcUa Galaxy Host (out-of-process MXAccess)' `
- start= auto `
- obj= $ServiceAccount | Out-Null
+ Write-Host "Installing OtOpcUaWonderwareHistorian..."
+ & sc.exe create OtOpcUaWonderwareHistorian binPath= "`"$InstallRoot\WonderwareHistorian\OtOpcUa.Driver.Historian.Wonderware.exe`"" `
+ DisplayName= 'OtOpcUa Wonderware Historian Sidecar (out-of-process aahClient)' `
+ start= auto `
+ depend= ($AvevaServiceDependencies -join '/') `
+ obj= $ServiceAccount | Out-Null
+ & sc.exe config OtOpcUaWonderwareHistorian start= delayed-auto | Out-Null
-# Set per-service environment variables via the registry — sc.exe doesn't expose them directly.
-$svcKey = "HKLM:\SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost"
-$envValue = $galaxyEnv.Split("`0") | Where-Object { $_ -ne '' }
-Set-ItemProperty -Path $svcKey -Name 'Environment' -Type MultiString -Value $envValue
+ $svcKey = "HKLM:\SYSTEM\CurrentControlSet\Services\OtOpcUaWonderwareHistorian"
+ $envValue = $historianEnv.Split("`0") | Where-Object { $_ -ne '' }
+ Set-ItemProperty -Path $svcKey -Name 'Environment' -Type MultiString -Value $envValue
+
+ $historianDepend = 'OtOpcUaWonderwareHistorian'
+}
+
+# --- Install OtOpcUa. Galaxy access flows through GalaxyDriver → mxaccessgw (gRPC),
+# so OtOpcUa no longer depends on a sibling service for Galaxy connectivity. The
+# mxaccessgw is installed separately. When the Wonderware sidecar is installed,
+# depend on it for startup ordering.
+$otOpcUaDepends = @()
+if ($historianDepend) { $otOpcUaDepends += $historianDepend }
-# --- Install OtOpcUa (depends on Galaxy host being installed; doesn't strictly require it
-# started — OtOpcUa.Server NodeBootstrap retries on the IPC connect path).
Write-Host "Installing OtOpcUa..."
-& sc.exe create OtOpcUa binPath= "`"$InstallRoot\OtOpcUa.Server.exe`"" `
- DisplayName= 'OtOpcUa Server' `
- start= auto `
- depend= 'OtOpcUaGalaxyHost' `
- obj= $ServiceAccount | Out-Null
+$createArgs = @(
+ 'create', 'OtOpcUa',
+ 'binPath=', "`"$InstallRoot\OtOpcUa.Server.exe`"",
+ 'DisplayName=', 'OtOpcUa Server',
+ 'start=', 'auto',
+ 'obj=', $ServiceAccount
+)
+if ($otOpcUaDepends.Count -gt 0) {
+ $createArgs += @('depend=', ($otOpcUaDepends -join '/'))
+}
+& sc.exe @createArgs | Out-Null
Write-Host ""
Write-Host "Installed. Start with:"
-Write-Host " sc.exe start OtOpcUaGalaxyHost"
+if ($InstallWonderwareHistorian) { Write-Host " sc.exe start OtOpcUaWonderwareHistorian" }
Write-Host " sc.exe start OtOpcUa"
+if ($InstallWonderwareHistorian) {
+ Write-Host ""
+ Write-Host "Wonderware historian shared secret (configure into appsettings.json Historian:Wonderware:SharedSecret):"
+ Write-Host " $HistorianSharedSecret"
+}
Write-Host ""
-Write-Host "Galaxy shared secret (record this offline — required for service rebinding):"
-Write-Host " $GalaxySharedSecret"
+Write-Host "NOTE: Galaxy access flows through mxaccessgw — install + run that separately"
+Write-Host " per docs/v2/Galaxy.ParityRig.md. OtOpcUa connects via the Galaxy.Gateway"
+Write-Host " section of appsettings.json (default endpoint http://localhost:5120)."
diff --git a/scripts/install/Uninstall-Services.ps1 b/scripts/install/Uninstall-Services.ps1
index c811226..f5c8206 100644
--- a/scripts/install/Uninstall-Services.ps1
+++ b/scripts/install/Uninstall-Services.ps1
@@ -1,11 +1,18 @@
<#
.SYNOPSIS
- Stops + removes the two v2 services. Mirrors Install-Services.ps1.
+ Stops + removes the v2 services. Mirrors Install-Services.ps1.
+
+.DESCRIPTION
+ PR 7.2 retired the legacy OtOpcUaGalaxyHost service. Galaxy access now flows
+ through the in-process GalaxyDriver against a separately-installed mxaccessgw.
+ OtOpcUaGalaxyHost is included in the cleanup loop below so this script safely
+ removes it from any rig still carrying the legacy service from a pre-7.2
+ install.
#>
[CmdletBinding()] param()
$ErrorActionPreference = 'Continue'
-foreach ($svc in 'OtOpcUa', 'OtOpcUaGalaxyHost') {
+foreach ($svc in 'OtOpcUa', 'OtOpcUaWonderwareHistorian', 'OtOpcUaGalaxyHost') {
if (Get-Service $svc -ErrorAction SilentlyContinue) {
Write-Host "Stopping $svc..."
Stop-Service $svc -Force -ErrorAction SilentlyContinue
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Alarms/GalaxyAlarmTracker.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Alarms/GalaxyAlarmTracker.cs
deleted file mode 100644
index ea8ec19..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Alarms/GalaxyAlarmTracker.cs
+++ /dev/null
@@ -1,260 +0,0 @@
-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;
-
-///
-/// Subscribes to the four Galaxy alarm attributes (.InAlarm, .Priority,
-/// .DescAttrName, .Acked) per alarm-bearing attribute discovered during
-/// DiscoverAsync. Maintains one per alarm, raises
-/// on lifecycle transitions (Active / Unacknowledged /
-/// Acknowledged / Inactive). Ack path writes .AckMsg. Pure-logic state machine
-/// with delegate-based subscribe/write so it's testable against in-memory fakes.
-///
-///
-/// Transitions emitted (OPC UA Part 9 alarm lifecycle, simplified for the Galaxy model):
-///
-/// - Active — InAlarm false → true. Default to Unacknowledged.
-/// - Acknowledged — Acked false → true while InAlarm is still true.
-/// - Inactive — InAlarm true → false. If still unacknowledged the alarm
-/// is marked latched-inactive-unack; next Ack transitions straight to Inactive.
-///
-///
-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, Task> _subscribe;
- private readonly Func _unsubscribe;
- private readonly Func> _write;
- private readonly Func _clock;
-
- // Alarm tag (attribute full ref, e.g. "Tank.Level.HiHi") → state.
- private readonly ConcurrentDictionary _alarms =
- new(StringComparer.OrdinalIgnoreCase);
-
- // Reverse lookup: probed tag (".InAlarm" etc.) → owning alarm tag.
- private readonly ConcurrentDictionary _probeToAlarm =
- new(StringComparer.OrdinalIgnoreCase);
-
- private bool _disposed;
-
- public event EventHandler? TransitionRaised;
-
- public GalaxyAlarmTracker(
- Func, Task> subscribe,
- Func unsubscribe,
- Func> write)
- : this(subscribe, unsubscribe, write, () => DateTime.UtcNow) { }
-
- internal GalaxyAlarmTracker(
- Func, Task> subscribe,
- Func unsubscribe,
- Func> write,
- Func 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;
-
- ///
- /// Advise the four alarm attributes for . 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.
- ///
- 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;
- }
- }
-
- ///
- /// Drop every tracked alarm. Unadvises all 4 probes per alarm as best-effort.
- ///
- 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 { }
- }
- }
-
- ///
- /// Operator ack — write the comment text into <alarmTag>.AckMsg.
- /// Returns false when the runtime reports the write failed.
- ///
- public Task AcknowledgeAsync(string alarmTag, string comment)
- {
- if (_disposed || string.IsNullOrWhiteSpace(alarmTag))
- return Task.FromResult(false);
- return _write(alarmTag + AckMsgAttr, comment ?? string.Empty);
- }
-
- ///
- /// 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.
- ///
- 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 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);
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/DbBackedGalaxyBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/DbBackedGalaxyBackend.cs
deleted file mode 100644
index 63300be..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/DbBackedGalaxyBackend.cs
+++ /dev/null
@@ -1,188 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
-using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
-
-///
-/// Galaxy backend that uses the live ZB repository for —
-/// real gobject hierarchy + attributes flow through to the Proxy without needing the MXAccess
-/// COM client. Runtime data-plane calls (Read/Write/Subscribe/Alarm/History) still surface
-/// as "MXAccess code lift pending" until the COM client port lands. This is the highest-value
-/// intermediate state because Discover is what powers the OPC UA address-space build, so
-/// downstream Proxy + parity tests can exercise the complete tree shape today.
-///
-public sealed class DbBackedGalaxyBackend(GalaxyRepository repository) : IGalaxyBackend
-{
- 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? OnDataChange;
- public event System.EventHandler? OnAlarmEvent;
- public event System.EventHandler? OnHostStatusChanged;
-#pragma warning restore CS0067
-
- public Task OpenSessionAsync(OpenSessionRequest req, CancellationToken ct)
- {
- var id = Interlocked.Increment(ref _nextSessionId);
- return Task.FromResult(new OpenSessionResponse { Success = true, SessionId = id });
- }
-
- public Task CloseSessionAsync(CloseSessionRequest req, CancellationToken ct) => Task.CompletedTask;
-
- public async Task DiscoverAsync(DiscoverHierarchyRequest req, CancellationToken ct)
- {
- try
- {
- var hierarchy = await repository.GetHierarchyAsync(ct).ConfigureAwait(false);
- var attributes = await repository.GetAttributesAsync(ct).ConfigureAwait(false);
-
- // Group attributes by their owning gobject for the IPC payload.
- var attrsByGobject = attributes
- .GroupBy(a => a.GobjectId)
- .ToDictionary(g => g.Key, g => g.Select(MapAttribute).ToArray());
-
- var parentByChild = hierarchy
- .ToDictionary(o => o.GobjectId, o => o.ParentGobjectId);
- var nameByGobject = hierarchy
- .ToDictionary(o => o.GobjectId, o => o.TagName);
-
- var objects = hierarchy.Select(o => new GalaxyObjectInfo
- {
- ContainedName = string.IsNullOrEmpty(o.ContainedName) ? o.TagName : o.ContainedName,
- TagName = o.TagName,
- ParentContainedName = parentByChild.TryGetValue(o.GobjectId, out var p)
- && p != 0
- && nameByGobject.TryGetValue(p, out var pName)
- ? pName
- : null,
- TemplateCategory = MapCategory(o.CategoryId),
- Attributes = attrsByGobject.TryGetValue(o.GobjectId, out var a) ? a : System.Array.Empty(),
- }).ToArray();
-
- return new DiscoverHierarchyResponse { Success = true, Objects = objects };
- }
- catch (Exception ex) when (ex is System.Data.SqlClient.SqlException
- or InvalidOperationException
- or TimeoutException)
- {
- return new DiscoverHierarchyResponse
- {
- Success = false,
- Error = $"Galaxy ZB repository error: {ex.Message}",
- Objects = System.Array.Empty(),
- };
- }
- }
-
- public Task ReadValuesAsync(ReadValuesRequest req, CancellationToken ct)
- => Task.FromResult(new ReadValuesResponse
- {
- Success = false,
- Error = "MXAccess code lift pending (Phase 2 Task B.1) — DB-backed backend covers Discover only",
- Values = System.Array.Empty(),
- });
-
- public Task WriteValuesAsync(WriteValuesRequest req, CancellationToken ct)
- {
- var results = new WriteValueResult[req.Writes.Length];
- for (var i = 0; i < req.Writes.Length; i++)
- {
- results[i] = new WriteValueResult
- {
- TagReference = req.Writes[i].TagReference,
- StatusCode = 0x80020000u,
- Error = "MXAccess code lift pending (Phase 2 Task B.1)",
- };
- }
- return Task.FromResult(new WriteValuesResponse { Results = results });
- }
-
- public Task SubscribeAsync(SubscribeRequest req, CancellationToken ct)
- {
- var sid = Interlocked.Increment(ref _nextSubscriptionId);
- return Task.FromResult(new SubscribeResponse
- {
- Success = true,
- SubscriptionId = sid,
- ActualIntervalMs = req.RequestedIntervalMs,
- });
- }
-
- public Task UnsubscribeAsync(UnsubscribeRequest req, CancellationToken ct) => Task.CompletedTask;
- public Task SubscribeAlarmsAsync(AlarmSubscribeRequest req, CancellationToken ct) => Task.CompletedTask;
- public Task AcknowledgeAlarmAsync(AlarmAckRequest req, CancellationToken ct) => Task.CompletedTask;
-
- public Task HistoryReadAsync(HistoryReadRequest req, CancellationToken ct)
- => Task.FromResult(new HistoryReadResponse
- {
- Success = false,
- Error = "MXAccess + Historian code lift pending (Phase 2 Task B.1)",
- Tags = System.Array.Empty(),
- });
-
- public Task 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(),
- });
-
- public Task 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(),
- });
-
- public Task 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(),
- });
-
- public Task RecycleAsync(RecycleHostRequest req, CancellationToken ct)
- => Task.FromResult(new RecycleStatusResponse { Accepted = true, GraceSeconds = 15 });
-
- private static GalaxyAttributeInfo MapAttribute(GalaxyAttributeRow row) => new()
- {
- AttributeName = row.AttributeName,
- MxDataType = row.MxDataType,
- IsArray = row.IsArray,
- ArrayDim = row.ArrayDimension is int d and > 0 ? (uint)d : null,
- SecurityClassification = row.SecurityClassification,
- IsHistorized = row.IsHistorized,
- IsAlarm = row.IsAlarm,
- };
-
- ///
- /// Galaxy template_definition.category_id → human-readable name.
- /// Mirrors v1 Host's AlarmObjectFilter mapping.
- ///
- private static string MapCategory(int categoryId) => categoryId switch
- {
- 1 => "$WinPlatform",
- 3 => "$AppEngine",
- 4 => "$Area",
- 10 => "$UserDefined",
- 11 => "$ApplicationObject",
- 13 => "$Area",
- 17 => "$DeviceIntegration",
- 24 => "$ViewEngine",
- 26 => "$ViewApp",
- _ => $"category-{categoryId}",
- };
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyHierarchyRow.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyHierarchyRow.cs
deleted file mode 100644
index 8f0ede4..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyHierarchyRow.cs
+++ /dev/null
@@ -1,35 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
-
-///
-/// One row from the v1 HierarchySql. Galaxy gobject deployed instance with its
-/// hierarchy parent + template-chain context.
-///
-public sealed class GalaxyHierarchyRow
-{
- public int GobjectId { get; init; }
- public string TagName { get; init; } = string.Empty;
- public string ContainedName { get; init; } = string.Empty;
- public string BrowseName { get; init; } = string.Empty;
- public int ParentGobjectId { get; init; }
- public bool IsArea { get; init; }
- public int CategoryId { get; init; }
- public int HostedByGobjectId { get; init; }
- public System.Collections.Generic.IReadOnlyList TemplateChain { get; init; } = System.Array.Empty();
-}
-
-/// One row from the v1 AttributesSql.
-public sealed class GalaxyAttributeRow
-{
- public int GobjectId { get; init; }
- public string TagName { get; init; } = string.Empty;
- public string AttributeName { get; init; } = string.Empty;
- public string FullTagReference { get; init; } = string.Empty;
- public int MxDataType { get; init; }
- public string? DataTypeName { get; init; }
- public bool IsArray { get; init; }
- public int? ArrayDimension { get; init; }
- public int MxAttributeCategory { get; init; }
- public int SecurityClassification { get; init; }
- public bool IsHistorized { get; init; }
- public bool IsAlarm { get; init; }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepository.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepository.cs
deleted file mode 100644
index 2d511be..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepository.cs
+++ /dev/null
@@ -1,224 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Data.SqlClient;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
-
-///
-/// SQL access to the Galaxy ZB repository — port of v1 GalaxyRepositoryService.
-/// The two SQL bodies (Hierarchy + Attributes) are byte-for-byte identical to v1 so the
-/// queries surface the same row set at parity time. Extended-attributes and scope-filter
-/// queries from v1 are intentionally not ported yet — they're refinements that aren't on
-/// the Phase 2 critical path.
-///
-public sealed class GalaxyRepository(GalaxyRepositoryOptions options)
-{
- public async Task TestConnectionAsync(CancellationToken ct = default)
- {
- try
- {
- using var conn = new SqlConnection(options.ConnectionString);
- await conn.OpenAsync(ct).ConfigureAwait(false);
- using var cmd = new SqlCommand("SELECT 1", conn) { CommandTimeout = options.CommandTimeoutSeconds };
- var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
- return result is int i && i == 1;
- }
- catch (SqlException) { return false; }
- catch (InvalidOperationException) { return false; }
- }
-
- public async Task GetLastDeployTimeAsync(CancellationToken ct = default)
- {
- using var conn = new SqlConnection(options.ConnectionString);
- await conn.OpenAsync(ct).ConfigureAwait(false);
- using var cmd = new SqlCommand("SELECT time_of_last_deploy FROM galaxy", conn)
- { CommandTimeout = options.CommandTimeoutSeconds };
- var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
- return result is DateTime dt ? dt : null;
- }
-
- public async Task> GetHierarchyAsync(CancellationToken ct = default)
- {
- var rows = new List();
-
- using var conn = new SqlConnection(options.ConnectionString);
- await conn.OpenAsync(ct).ConfigureAwait(false);
-
- using var cmd = new SqlCommand(HierarchySql, conn) { CommandTimeout = options.CommandTimeoutSeconds };
- using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
-
- while (await reader.ReadAsync(ct).ConfigureAwait(false))
- {
- var templateChainRaw = reader.IsDBNull(8) ? string.Empty : reader.GetString(8);
- var templateChain = templateChainRaw.Length == 0
- ? Array.Empty()
- : templateChainRaw.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries)
- .Select(s => s.Trim())
- .Where(s => s.Length > 0)
- .ToArray();
-
- rows.Add(new GalaxyHierarchyRow
- {
- GobjectId = Convert.ToInt32(reader.GetValue(0)),
- TagName = reader.GetString(1),
- ContainedName = reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
- BrowseName = reader.GetString(3),
- ParentGobjectId = Convert.ToInt32(reader.GetValue(4)),
- IsArea = Convert.ToInt32(reader.GetValue(5)) == 1,
- CategoryId = Convert.ToInt32(reader.GetValue(6)),
- HostedByGobjectId = Convert.ToInt32(reader.GetValue(7)),
- TemplateChain = templateChain,
- });
- }
- return rows;
- }
-
- public async Task> GetAttributesAsync(CancellationToken ct = default)
- {
- var rows = new List();
-
- using var conn = new SqlConnection(options.ConnectionString);
- await conn.OpenAsync(ct).ConfigureAwait(false);
-
- using var cmd = new SqlCommand(AttributesSql, conn) { CommandTimeout = options.CommandTimeoutSeconds };
- using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
-
- while (await reader.ReadAsync(ct).ConfigureAwait(false))
- {
- rows.Add(new GalaxyAttributeRow
- {
- GobjectId = Convert.ToInt32(reader.GetValue(0)),
- TagName = reader.GetString(1),
- AttributeName = reader.GetString(2),
- FullTagReference = reader.GetString(3),
- MxDataType = Convert.ToInt32(reader.GetValue(4)),
- DataTypeName = reader.IsDBNull(5) ? null : reader.GetString(5),
- IsArray = Convert.ToInt32(reader.GetValue(6)) == 1,
- ArrayDimension = reader.IsDBNull(7) ? (int?)null : Convert.ToInt32(reader.GetValue(7)),
- MxAttributeCategory = Convert.ToInt32(reader.GetValue(8)),
- SecurityClassification = Convert.ToInt32(reader.GetValue(9)),
- IsHistorized = Convert.ToInt32(reader.GetValue(10)) == 1,
- IsAlarm = Convert.ToInt32(reader.GetValue(11)) == 1,
- });
- }
- return rows;
- }
-
- private const string HierarchySql = @"
-;WITH template_chain AS (
- SELECT g.gobject_id AS instance_gobject_id, t.gobject_id AS template_gobject_id,
- t.tag_name AS template_tag_name, t.derived_from_gobject_id, 0 AS depth
- FROM gobject g
- INNER JOIN gobject t ON t.gobject_id = g.derived_from_gobject_id
- WHERE g.is_template = 0 AND g.deployed_package_id <> 0 AND g.derived_from_gobject_id <> 0
- UNION ALL
- SELECT tc.instance_gobject_id, t.gobject_id, t.tag_name, t.derived_from_gobject_id, tc.depth + 1
- FROM template_chain tc
- INNER JOIN gobject t ON t.gobject_id = tc.derived_from_gobject_id
- WHERE tc.derived_from_gobject_id <> 0 AND tc.depth < 10
-)
-SELECT DISTINCT
- g.gobject_id,
- g.tag_name,
- g.contained_name,
- CASE WHEN g.contained_name IS NULL OR g.contained_name = ''
- THEN g.tag_name
- ELSE g.contained_name
- END AS browse_name,
- CASE WHEN g.contained_by_gobject_id = 0
- THEN g.area_gobject_id
- ELSE g.contained_by_gobject_id
- END AS parent_gobject_id,
- CASE WHEN td.category_id = 13
- THEN 1
- ELSE 0
- END AS is_area,
- td.category_id AS category_id,
- g.hosted_by_gobject_id AS hosted_by_gobject_id,
- ISNULL(
- STUFF((
- SELECT '|' + tc.template_tag_name
- FROM template_chain tc
- WHERE tc.instance_gobject_id = g.gobject_id
- ORDER BY tc.depth
- FOR XML PATH('')
- ), 1, 1, ''),
- ''
- ) AS template_chain
-FROM gobject g
-INNER JOIN template_definition td
- ON g.template_definition_id = td.template_definition_id
-WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
- AND g.is_template = 0
- AND g.deployed_package_id <> 0
-ORDER BY parent_gobject_id, g.tag_name";
-
- private const string AttributesSql = @"
-;WITH deployed_package_chain AS (
- SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth
- FROM gobject g
- INNER JOIN package p ON p.package_id = g.deployed_package_id
- WHERE g.is_template = 0 AND g.deployed_package_id <> 0
- UNION ALL
- SELECT dpc.gobject_id, p.package_id, p.derived_from_package_id, dpc.depth + 1
- FROM deployed_package_chain dpc
- INNER JOIN package p ON p.package_id = dpc.derived_from_package_id
- WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10
-)
-SELECT gobject_id, tag_name, attribute_name, full_tag_reference,
- mx_data_type, data_type_name, is_array, array_dimension,
- mx_attribute_category, security_classification, is_historized, is_alarm
-FROM (
- SELECT
- dpc.gobject_id,
- g.tag_name,
- da.attribute_name,
- g.tag_name + '.' + da.attribute_name
- + CASE WHEN da.is_array = 1 THEN '[]' ELSE '' END
- AS full_tag_reference,
- da.mx_data_type,
- dt.description AS data_type_name,
- da.is_array,
- CASE WHEN da.is_array = 1
- THEN CONVERT(int, CONVERT(varbinary(2),
- SUBSTRING(da.mx_value, 15, 2) + SUBSTRING(da.mx_value, 13, 2), 2))
- ELSE NULL
- END AS array_dimension,
- da.mx_attribute_category,
- da.security_classification,
- CASE WHEN EXISTS (
- SELECT 1 FROM deployed_package_chain dpc2
- INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name
- INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'HistoryExtension'
- WHERE dpc2.gobject_id = dpc.gobject_id
- ) THEN 1 ELSE 0 END AS is_historized,
- CASE WHEN EXISTS (
- SELECT 1 FROM deployed_package_chain dpc2
- INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name
- INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'AlarmExtension'
- WHERE dpc2.gobject_id = dpc.gobject_id
- ) THEN 1 ELSE 0 END AS is_alarm,
- ROW_NUMBER() OVER (
- PARTITION BY dpc.gobject_id, da.attribute_name
- ORDER BY dpc.depth
- ) AS rn
- FROM deployed_package_chain dpc
- INNER JOIN dynamic_attribute da
- ON da.package_id = dpc.package_id
- INNER JOIN gobject g
- ON g.gobject_id = dpc.gobject_id
- INNER JOIN template_definition td
- ON td.template_definition_id = g.template_definition_id
- LEFT JOIN data_type dt
- ON dt.mx_data_type = da.mx_data_type
- WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
- AND da.attribute_name NOT LIKE '[_]%'
- AND da.attribute_name NOT LIKE '%.Description'
- AND da.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24)
-) ranked
-WHERE rn = 1
-ORDER BY tag_name, attribute_name";
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepositoryOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepositoryOptions.cs
deleted file mode 100644
index b72a759..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Galaxy/GalaxyRepositoryOptions.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
-
-///
-/// Connection settings for the Galaxy ZB repository database. Set from the
-/// DriverConfig JSON section Database per plan.md §"Galaxy DriverConfig".
-///
-public sealed class GalaxyRepositoryOptions
-{
- public string ConnectionString { get; init; } =
- "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;";
-
- public int CommandTimeoutSeconds { get; init; } = 60;
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/IGalaxyBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/IGalaxyBackend.cs
deleted file mode 100644
index 5f7329b..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/IGalaxyBackend.cs
+++ /dev/null
@@ -1,46 +0,0 @@
-using System.Collections.Generic;
-using System.Threading;
-using System.Threading.Tasks;
-using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
-
-///
-/// Galaxy data-plane abstraction. Replaces the placeholder StubFrameHandler with a
-/// real boundary the lifted MxAccessClient + GalaxyRepository implement during
-/// Phase 2 Task B.1. Splitting the IPC dispatch (GalaxyFrameHandler) from the
-/// backend means the dispatcher is unit-testable against an in-memory mock without needing
-/// live Galaxy.
-///
-public interface IGalaxyBackend
-{
- ///
- /// 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 notification.
- ///
- event System.EventHandler? OnDataChange;
- event System.EventHandler? OnAlarmEvent;
- event System.EventHandler? OnHostStatusChanged;
-
- Task OpenSessionAsync(OpenSessionRequest req, CancellationToken ct);
- Task CloseSessionAsync(CloseSessionRequest req, CancellationToken ct);
-
- Task DiscoverAsync(DiscoverHierarchyRequest req, CancellationToken ct);
-
- Task ReadValuesAsync(ReadValuesRequest req, CancellationToken ct);
- Task WriteValuesAsync(WriteValuesRequest req, CancellationToken ct);
-
- Task SubscribeAsync(SubscribeRequest req, CancellationToken ct);
- Task UnsubscribeAsync(UnsubscribeRequest req, CancellationToken ct);
-
- Task SubscribeAlarmsAsync(AlarmSubscribeRequest req, CancellationToken ct);
- Task AcknowledgeAlarmAsync(AlarmAckRequest req, CancellationToken ct);
-
- Task HistoryReadAsync(HistoryReadRequest req, CancellationToken ct);
- Task HistoryReadProcessedAsync(HistoryReadProcessedRequest req, CancellationToken ct);
- Task HistoryReadAtTimeAsync(HistoryReadAtTimeRequest req, CancellationToken ct);
- Task HistoryReadEventsAsync(HistoryReadEventsRequest req, CancellationToken ct);
-
- Task RecycleAsync(RecycleHostRequest req, CancellationToken ct);
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/IMxProxy.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/IMxProxy.cs
deleted file mode 100644
index 5ab9e72..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/IMxProxy.cs
+++ /dev/null
@@ -1,43 +0,0 @@
-using ArchestrA.MxAccess;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
-
-///
-/// Delegate matching LMXProxyServer.OnDataChange COM event signature. Allows
-/// to subscribe via the abstracted
-/// instead of the COM object directly (so the test mock works without MXAccess registered).
-///
-public delegate void MxDataChangeHandler(
- int hLMXServerHandle,
- int phItemHandle,
- object pvItemValue,
- int pwItemQuality,
- object pftItemTimeStamp,
- ref MXSTATUS_PROXY[] ItemStatus);
-
-public delegate void MxWriteCompleteHandler(
- int hLMXServerHandle,
- int phItemHandle,
- ref MXSTATUS_PROXY[] ItemStatus);
-
-///
-/// Abstraction over LMXProxyServer — port of v1 IMxProxy. Same surface area
-/// so the lifted client behaves identically; only the namespace + apartment-marshalling
-/// entry-point change.
-///
-public interface IMxProxy
-{
- int Register(string clientName);
- void Unregister(int handle);
-
- int AddItem(int handle, string address);
- void RemoveItem(int handle, int itemHandle);
-
- void AdviseSupervisory(int handle, int itemHandle);
- void UnAdviseSupervisory(int handle, int itemHandle);
-
- void Write(int handle, int itemHandle, object value, int securityClassification);
-
- event MxDataChangeHandler? OnDataChange;
- event MxWriteCompleteHandler? OnWriteComplete;
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/MxAccessClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/MxAccessClient.cs
deleted file mode 100644
index 6fd9bd0..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/MxAccessClient.cs
+++ /dev/null
@@ -1,408 +0,0 @@
-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;
-
-///
-/// MXAccess runtime client — focused port of v1 MxAccessClient. Owns one
-/// LMXProxyServer COM connection on the supplied ; serializes
-/// read / write / subscribe through the pump because all COM calls must run on the STA
-/// thread. Subscriptions are stored so they can be replayed on reconnect (full reconnect
-/// loop is the deferred-but-non-blocking refinement; this version covers connect/read/write
-/// /subscribe/unsubscribe — the MVP needed for parity testing).
-///
-public sealed class MxAccessClient : IDisposable
-{
- private static readonly ILogger Log = Serilog.Log.ForContext();
-
- 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 _addressToHandle = new(StringComparer.OrdinalIgnoreCase);
- private readonly ConcurrentDictionary _handleToAddress = new();
- private readonly ConcurrentDictionary> _subscriptions =
- new(StringComparer.OrdinalIgnoreCase);
- private readonly ConcurrentDictionary> _pendingWrites = new();
-
- private int _connectionHandle;
- private bool _connected;
- private DateTime _lastObservedActivityUtc = DateTime.UtcNow;
- private CancellationTokenSource? _monitorCts;
- private int _reconnectCount;
- private bool _disposed;
-
- /// Fires whenever the connection transitions Connected ↔ Disconnected.
- public event EventHandler? ConnectionStateChanged;
-
- ///
- /// 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.
- ///
- public event EventHandler? 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;
-
- ///
- /// Wonderware client identity used when registering with the LMXProxyServer. Surfaced so
- /// can tag its OnHostStatusChanged IPC
- /// pushes with a stable gateway name per PR 8.
- ///
- public string ClientName => _clientName;
-
- /// Connects on the STA thread. Idempotent. Starts the reconnect monitor on first call.
- public async Task ConnectAsync()
- {
- var handle = await _pump.InvokeAsync(() =>
- {
- 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);
- }
-
- ///
- /// 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.
- ///
- 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;
- }
- }
- }
-
- ///
- /// One-shot read implemented as a transient subscribe + unsubscribe.
- /// LMXProxyServer doesn't expose a synchronous read, so the canonical pattern
- /// (lifted from v1) is to subscribe, await the first OnDataChange, then unsubscribe.
- /// This method captures that single value.
- ///
- public async Task ReadAsync(string fullReference, TimeSpan timeout, CancellationToken ct)
- {
- if (!_connected) throw new InvalidOperationException("MxAccessClient not connected");
-
- var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
- Action oneShot = (_, value) => tcs.TrySetResult(value);
-
- // 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);
-
- 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}");
-
- 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 */ }
- }
- }
- }
-
- ///
- /// Writes to the runtime and AWAITS the OnWriteComplete
- /// callback so the caller learns the actual write status. Per Phase 2 medium finding #4
- /// in exit-gate-phase-2.md: the previous fire-and-forget version returned a
- /// false-positive Good even when the runtime rejected the write post-callback.
- ///
- public async Task 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(TaskCreationOptions.RunContinuationsAsynchronously);
- if (!_pendingWrites.TryAdd(itemHandle, tcs))
- {
- // 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 callback)
- {
- if (!_connected) throw new InvalidOperationException("MxAccessClient not connected");
-
- _subscriptions.AddOrUpdate(fullReference, callback, (_, existing) => Combine(existing, callback));
- await SubscribeOnPumpAsync(fullReference);
- }
-
- public Task UnsubscribeAsync(string fullReference) => _pump.InvokeAsync(() =>
- {
- if (!_connected) return;
- if (!_addressToHandle.TryRemove(fullReference, out var handle)) return;
- _handleToAddress.TryRemove(handle, out _);
- _subscriptions.TryRemove(fullReference, out _);
-
- try
- {
- _proxy.UnAdviseSupervisory(_connectionHandle, handle);
- _proxy.RemoveItem(_connectionHandle, handle);
- }
- catch { /* best-effort during teardown */ }
- });
-
- private Task SubscribeOnPumpAsync(string fullReference) => _pump.InvokeAsync(() =>
- {
- if (_addressToHandle.TryGetValue(fullReference, out var existing)) return existing;
-
- var itemHandle = _proxy.AddItem(_connectionHandle, fullReference);
- _addressToHandle[fullReference] = itemHandle;
- _handleToAddress[itemHandle] = fullReference;
- _proxy.AdviseSupervisory(_connectionHandle, itemHandle);
- return itemHandle;
- });
-
- private int ResolveItem(string fullReference)
- {
- if (_addressToHandle.TryGetValue(fullReference, out var existing)) return existing;
- var itemHandle = _proxy.AddItem(_connectionHandle, fullReference);
- _addressToHandle[fullReference] = itemHandle;
- _handleToAddress[itemHandle] = fullReference;
- return itemHandle;
- }
-
- private void OnDataChange(int hLMXServerHandle, int phItemHandle, object pvItemValue,
- int pwItemQuality, object pftItemTimeStamp, ref MXSTATUS_PROXY[] itemStatus)
- {
- 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);
-
- if (_subscriptions.TryGetValue(fullRef, out var cb)) cb?.Invoke(fullRef, vtq);
- }
-
- private void OnWriteComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] itemStatus)
- {
- if (_pendingWrites.TryRemove(phItemHandle, out var tcs))
- tcs.TrySetResult(itemStatus is null || itemStatus.Length == 0 || itemStatus[0].success != 0);
- }
-
- private static Action Combine(Action a, Action b)
- => (Action)Delegate.Combine(a, b)!;
-
- private static Action Remove(Action source, Action remove)
- => (Action?)Delegate.Remove(source, remove) ?? ((_, _) => { });
-
- public void Dispose()
- {
- _disposed = true;
- _monitorCts?.Cancel();
-
- try { DisconnectAsync().GetAwaiter().GetResult(); }
- catch { /* swallow */ }
-
- _proxy.OnDataChange -= OnDataChange;
- _proxy.OnWriteComplete -= OnWriteComplete;
- _monitorCts?.Dispose();
- }
-}
-
-///
-/// Tunables for 's reconnect monitor. Defaults match the v1
-/// monitor's polling cadence so behavior is consistent across the lift.
-///
-public sealed class MxAccessClientOptions
-{
- /// Whether to start the background monitor at connect time.
- public bool AutoReconnect { get; init; } = true;
-
- /// How often the monitor wakes up to check liveness.
- public TimeSpan MonitorInterval { get; init; } = TimeSpan.FromSeconds(5);
-
- /// If no data-change activity in this window, the monitor probes the connection.
- public TimeSpan StaleThreshold { get; init; } = TimeSpan.FromSeconds(60);
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/MxProxyAdapter.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/MxProxyAdapter.cs
deleted file mode 100644
index b16ef86..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/MxProxyAdapter.cs
+++ /dev/null
@@ -1,68 +0,0 @@
-using System;
-using System.Runtime.InteropServices;
-using ArchestrA.MxAccess;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
-
-///
-/// Concrete backed by a real LMXProxyServer COM object.
-/// Port of v1 MxProxyAdapter. Must only be constructed on an STA thread
-/// — the StaPump owns this instance.
-///
-public sealed class MxProxyAdapter : IMxProxy, IDisposable
-{
- private LMXProxyServer? _lmxProxy;
-
- public event MxDataChangeHandler? OnDataChange;
- public event MxWriteCompleteHandler? OnWriteComplete;
-
- public int Register(string clientName)
- {
- _lmxProxy = new LMXProxyServer();
- _lmxProxy.OnDataChange += ProxyOnDataChange;
- _lmxProxy.OnWriteComplete += ProxyOnWriteComplete;
-
- var handle = _lmxProxy.Register(clientName);
- if (handle <= 0)
- throw new InvalidOperationException($"LMXProxyServer.Register returned invalid handle: {handle}");
- return handle;
- }
-
- public void Unregister(int handle)
- {
- if (_lmxProxy is null) return;
- try
- {
- _lmxProxy.OnDataChange -= ProxyOnDataChange;
- _lmxProxy.OnWriteComplete -= ProxyOnWriteComplete;
- _lmxProxy.Unregister(handle);
- }
- finally
- {
- // ReleaseComObject loop until refcount = 0 — the Tier C SafeHandle wraps this in
- // production; here the lifetime is owned by the surrounding MxAccessHandle.
- while (Marshal.IsComObject(_lmxProxy) && Marshal.ReleaseComObject(_lmxProxy) > 0) { }
- _lmxProxy = null;
- }
- }
-
- public int AddItem(int handle, string address) => _lmxProxy!.AddItem(handle, address);
-
- public void RemoveItem(int handle, int itemHandle) => _lmxProxy!.RemoveItem(handle, itemHandle);
-
- public void AdviseSupervisory(int handle, int itemHandle) => _lmxProxy!.AdviseSupervisory(handle, itemHandle);
-
- public void UnAdviseSupervisory(int handle, int itemHandle) => _lmxProxy!.UnAdvise(handle, itemHandle);
-
- public void Write(int handle, int itemHandle, object value, int securityClassification) =>
- _lmxProxy!.Write(handle, itemHandle, value, securityClassification);
-
- private void ProxyOnDataChange(int hLMXServerHandle, int phItemHandle, object pvItemValue,
- int pwItemQuality, object pftItemTimeStamp, ref MXSTATUS_PROXY[] ItemStatus)
- => OnDataChange?.Invoke(hLMXServerHandle, phItemHandle, pvItemValue, pwItemQuality, pftItemTimeStamp, ref ItemStatus);
-
- private void ProxyOnWriteComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] ItemStatus)
- => OnWriteComplete?.Invoke(hLMXServerHandle, phItemHandle, ref ItemStatus);
-
- public void Dispose() => Unregister(0);
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/SubscriptionReplayFailedEventArgs.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/SubscriptionReplayFailedEventArgs.cs
deleted file mode 100644
index ee8f03b..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/SubscriptionReplayFailedEventArgs.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-using System;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
-
-///
-/// Fired by 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.
-///
-public sealed class SubscriptionReplayFailedEventArgs : EventArgs
-{
- public SubscriptionReplayFailedEventArgs(string tagReference, Exception exception)
- {
- TagReference = tagReference;
- Exception = exception;
- }
-
- public string TagReference { get; }
- public Exception Exception { get; }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/Vtq.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/Vtq.cs
deleted file mode 100644
index 45ac067..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/Vtq.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-using System;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
-
-/// Value-timestamp-quality triplet — port of v1 Vtq.
-public readonly struct Vtq
-{
- public object? Value { get; }
- public DateTime TimestampUtc { get; }
- public byte Quality { get; }
-
- public Vtq(object? value, DateTime timestampUtc, byte quality)
- {
- Value = value;
- TimestampUtc = timestampUtc;
- Quality = quality;
- }
-
- /// OPC DA Good = 192.
- public static Vtq Good(object? v) => new(v, DateTime.UtcNow, 192);
-
- /// OPC DA Bad = 0.
- public static Vtq Bad() => new(null, DateTime.UtcNow, 0);
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs
deleted file mode 100644
index ff92a55..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs
+++ /dev/null
@@ -1,608 +0,0 @@
-using System;
-using System.Collections.Generic;
-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.Historian.Wonderware.Backend;
-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;
-
-///
-/// Production — combines the SQL-backed
-/// for Discover with the live MXAccess
-/// for Read / Write / Subscribe. History stays bad-coded
-/// until the Wonderware Historian SDK plugin loader (Task B.1.h) lands. Alarms come from
-/// MxAccess AlarmExtension primitives but the wire-up is also Phase 2 follow-up
-/// (the v1 alarm subsystem is its own subtree).
-///
-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> _subs = new();
- // Reverse lookup: tag reference → subscription IDs subscribed to it (one tag may belong to many).
- private readonly System.Collections.Concurrent.ConcurrentDictionary>
- _refToSubs = new(System.StringComparer.OrdinalIgnoreCase);
-
- public event System.EventHandler? OnDataChange;
- public event System.EventHandler? OnAlarmEvent;
- public event System.EventHandler? OnHostStatusChanged;
-
- private readonly System.EventHandler _onConnectionStateChanged;
- private readonly GalaxyRuntimeProbeManager _probeManager;
- private readonly System.EventHandler _onProbeStateChanged;
- private readonly GalaxyAlarmTracker _alarmTracker;
- private readonly System.EventHandler _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 _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;
- }
-
- ///
- /// Exposed for tests. Production flow: DiscoverAsync completes → backend calls
- /// SyncProbesAsync with the runtime hosts (WinPlatform + AppEngine gobjects) to
- /// advise ScanState per host.
- ///
- internal GalaxyRuntimeProbeManager ProbeManager => _probeManager;
-
- public async Task OpenSessionAsync(OpenSessionRequest req, CancellationToken ct)
- {
- try
- {
- await _mx.ConnectAsync();
- return new OpenSessionResponse { Success = true, SessionId = Interlocked.Increment(ref _nextSessionId) };
- }
- catch (Exception ex)
- {
- return new OpenSessionResponse { Success = false, Error = $"MXAccess connect failed: {ex.Message}" };
- }
- }
-
- public async Task CloseSessionAsync(CloseSessionRequest req, CancellationToken ct)
- {
- await _mx.DisconnectAsync();
- }
-
- public async Task DiscoverAsync(DiscoverHierarchyRequest req, CancellationToken ct)
- {
- try
- {
- var hierarchy = await _repository.GetHierarchyAsync(ct).ConfigureAwait(false);
- var attributes = await _repository.GetAttributesAsync(ct).ConfigureAwait(false);
-
- var attrsByGobject = attributes
- .GroupBy(a => a.GobjectId)
- .ToDictionary(g => g.Key, g => g.Select(MapAttribute).ToArray());
- var nameByGobject = hierarchy.ToDictionary(o => o.GobjectId, o => o.TagName);
-
- var objects = hierarchy.Select(o => new GalaxyObjectInfo
- {
- ContainedName = string.IsNullOrEmpty(o.ContainedName) ? o.TagName : o.ContainedName,
- TagName = o.TagName,
- ParentContainedName = o.ParentGobjectId != 0 && nameByGobject.TryGetValue(o.ParentGobjectId, out var p) ? p : null,
- TemplateCategory = MapCategory(o.CategoryId),
- Attributes = attrsByGobject.TryGetValue(o.GobjectId, out var a) ? a : Array.Empty(),
- }).ToArray();
-
- // PR 14: cache alarm-bearing attribute full refs so SubscribeAlarmsAsync can advise
- // them on demand. Format matches the Galaxy reference grammar ..
- 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()
- .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)
- {
- return new DiscoverHierarchyResponse { Success = false, Error = ex.Message, Objects = Array.Empty() };
- }
- }
-
- public async Task ReadValuesAsync(ReadValuesRequest req, CancellationToken ct)
- {
- if (!_mx.IsConnected) return new ReadValuesResponse { Success = false, Error = "Not connected", Values = Array.Empty() };
-
- var results = new List(req.TagReferences.Length);
- foreach (var reference in req.TagReferences)
- {
- try
- {
- var vtq = await _mx.ReadAsync(reference, TimeSpan.FromSeconds(5), ct);
- results.Add(ToWire(reference, vtq));
- }
- catch (Exception ex)
- {
- results.Add(new GalaxyDataValue
- {
- TagReference = reference,
- StatusCode = 0x80020000u, // Bad_InternalError
- ServerTimestampUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
- ValueBytes = MessagePackSerializer.Serialize(ex.Message),
- });
- }
- }
-
- return new ReadValuesResponse { Success = true, Values = results.ToArray() };
- }
-
- public async Task WriteValuesAsync(WriteValuesRequest req, CancellationToken ct)
- {
- var results = new List(req.Writes.Length);
- foreach (var w in req.Writes)
- {
- try
- {
- // Decode the value back from the MessagePack bytes the Proxy sent.
- var value = w.ValueBytes is null
- ? null
- : MessagePackSerializer.Deserialize