Compare commits

..

10 Commits

Author SHA1 Message Date
Joseph Doherty 9f7a4ac769 PR 3.3 — Wonderware sidecar pipe protocol + dispatcher
Sidecar now serves a length-prefixed, kind-tagged MessagePack pipe protocol
mirroring Galaxy.Host's: 4-byte BE length + 1-byte MessageKind + body, 16 MiB
cap. Hello handshake validates per-process shared secret + protocol major
version + caller SID via ImpersonateNamedPipeClient before any work frame
runs.

Five contract pairs ship in this PR:

  ReadRawRequest          ↔ ReadRawReply
  ReadProcessedRequest    ↔ ReadProcessedReply
  ReadAtTimeRequest       ↔ ReadAtTimeReply
  ReadEventsRequest       ↔ ReadEventsReply
  WriteAlarmEventsRequest ↔ WriteAlarmEventsReply

Timestamps cross the wire as DateTime ticks (long) to dodge MessagePack's
DateTime kind/timezone quirks; both sides convert with DateTime(ticks, Utc).
Sample values cross as MessagePack-serialized byte[] so the .NET 10 client
(PR 3.4) deserializes per the tag's mx_data_type without the sidecar needing
to know OPC UA types.

HistorianFrameHandler dispatches by MessageKind to IHistorianDataSource (the
PR 3.2 lifted interface) for reads, and to a new IAlarmEventWriter strategy
for the alarm-event persistence path. Per-call exceptions surface as
Success=false replies so a single bad request doesn't kill the connection.
WriteAlarmEvents replies carry per-event success flags; the SQLite
store-and-forward sink retries failed slots on the next drain tick.

Program.cs spins the pipe server when OTOPCUA_HISTORIAN_ENABLED=true. Pipe-
only mode (default false) preserves PR 3.1's smoke-test behaviour: the host
still validates env vars and waits for Ctrl-C, but doesn't initialize the
Wonderware SDK.

Sidecar test project gains 8 round-trip tests (37 total now): every contract
pair round-trips through FrameReader/FrameWriter via in-memory streams, the
handler surfaces historian exceptions cleanly, WriteAlarmEvents per-event
status flows through, and the no-writer-configured path returns a clean
error reply.

Added MessagePack 2.5.187 to the sidecar csproj.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:27:17 -04:00
Joseph Doherty bc7ec746c5 PR 1+2.W — Wire HistoryRouter + AlarmConditionService into DI
Server-side singletons threaded through OpcUaApplicationHost → OtOpcUaServer
→ DriverNodeManager construction. New ctor parameters are last-position
optional with null defaults so every existing test construction site
(OpcUaServerIntegrationTests, AlarmSubscribeIntegrationTests, etc.) keeps
working unchanged.

Program.cs:
  AddSingleton<IHistoryRouter, HistoryRouter>();
  AddSingleton<AlarmConditionService>();

The router stays empty after this PR. DriverNodeManager's internal
LegacyDriverHistoryAdapter handles every driver that still implements
IHistoryProvider; PR 3.W will register the Wonderware sidecar as a router
source; PR 7.2 retires the legacy fallback entirely.

44 alarm + history + integration tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:13:51 -04:00
Joseph Doherty 9365beb966 PR 3.2 — Lift Wonderware Historian SDK code to sidecar
Move all historian implementation files from Driver.Galaxy.Host/Backend/Historian/
to Driver.Historian.Wonderware/Backend/. Sidecar now owns the aahClientManaged /
aahClientCommon SDK references; Galaxy.Host project-references the sidecar so
MxAccessGalaxyBackend keeps building until PR 7.2 retires Galaxy.Host entirely.

10 source files moved (preserving git history via git mv):
  IHistorianDataSource, HistorianDataSource, HistorianClusterEndpointPicker,
  HistorianClusterNodeState, HistorianConfiguration, HistorianEventDto,
  HistorianHealthSnapshot, HistorianQualityMapper, HistorianSample,
  IHistorianConnectionFactory.

2 historian tests moved alongside (HistorianClusterEndpointPickerTests,
HistorianQualityMapperTests). Sidecar test project now hosts 29 tests (1 PR 3.1
smoke + 28 moved historian tests, all passing).

Galaxy.Host's remaining 6 historian-flavored tests (HistorianWiringTests,
HistoryReadAtTimeTests, HistoryReadEventsTests, HistoryReadProcessedTests)
keep passing via the project reference — using directives updated to reach
the new namespace.

Sidecar deliberately speaks no Core.Abstractions — its surface is the legacy
List<HistorianSample> shape; PR 3.4's .NET 10 client translates to the
Core.Abstractions shapes added in PR 1.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:13:13 -04:00
Joseph Doherty ef22a61c39 v2 mxgw migration — Phase 1+2+3.1 wiring (7 PRs)
Foundational PRs from lmx_mxgw_impl.md, all green. Bodies only — DI/wiring
deferred to PR 1+2.W (combined wire-up) and PR 3.W.

PR 1.1 — IHistorianDataSource lifted to Core.Abstractions/Historian/
  Reuses existing DataValueSnapshot + HistoricalEvent shapes; sidecar (PR
  3.4) translates byte-quality → uint StatusCode internally.

PR 1.2 — IHistoryRouter + HistoryRouter on the server
  Longest-prefix-match resolution, case-insensitive, ObjectDisposed-guarded,
  swallow-on-shutdown disposal of misbehaving sources.

PR 1.3 — DriverNodeManager.HistoryRead* dispatch through IHistoryRouter
  Per-tag resolution with LegacyDriverHistoryAdapter wrapping
  `_driver as IHistoryProvider` so existing tests + drivers keep working
  until PR 7.2 retires the fallback.

PR 2.1 — AlarmConditionInfo extended with five sub-attribute refs
  InAlarmRef / PriorityRef / DescAttrNameRef / AckedRef / AckMsgWriteRef.
  Optional defaulted parameters preserve all existing 3-arg call sites.

PR 2.2 — AlarmConditionService state machine in Server/Alarms/
  Driver-agnostic port of GalaxyAlarmTracker. Sub-attribute refs come from
  AlarmConditionInfo, values arrive as DataValueSnapshot, ack writes route
  through IAlarmAcknowledger. State machine preserves Active/Acknowledged/
  Inactive transitions, Acked-on-active reset, post-disposal silence.

PR 2.3 — DriverNodeManager wires AlarmConditionService
  MarkAsAlarmCondition registers each alarm-bearing variable with the
  service; DriverWritableAcknowledger routes ack-message writes through
  the driver's IWritable + CapabilityInvoker. Service-raised transitions
  route via OnAlarmServiceTransition → matching ConditionSink. Legacy
  IAlarmSource path unchanged for null service.

PR 3.1 — Driver.Historian.Wonderware shell project (net48 x86)
  Console host shell + smoke test; SDK references + code lift come in
  PR 3.2.

Tests: 9 (PR 1.1) + 5 (PR 2.1) + 10 (PR 1.2) + 19 (PR 2.2) + 1 (PR 3.1)
all pass. Existing AlarmSubscribeIntegrationTests + HistoryReadIntegrationTests
unchanged.

Plan + audit docs (lmx_backend.md, lmx_mxgw.md, lmx_mxgw_impl.md)
included so parallel subagent worktrees can read them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:03:36 -04:00
Joseph Doherty 012c42a846 Task #156 — TagsTab: per-tag advanced Modbus fields (Deadband, UnitId, CoalesceProhibited)
#155 wired the basic tag form (Name / Driver / Equipment / DataType / Access /
WriteIdempotent + ModbusAddressEditor for the address). The per-tag knobs added
across #141 / #142 / #143 still required operators to hand-edit TagConfig JSON.
This commit exposes them through an "Advanced" expander.

UI changes (TagsTab.razor):

- Collapsible "▶ Advanced (Deadband / UnitId override / CoalesceProhibited)"
  button below the address editor, visible only when the selected driver is
  Modbus. Collapsed by default — basic form covers the typical edit workflow.
- Three numeric / checkbox inputs with inline help text explaining each knob's
  purpose and when to use it.
- _showAdvanced auto-opens on Edit when any of the advanced fields are present
  in the existing TagConfig — operators see immediately what's been configured.

Save-side serialization:

- New RefreshTagConfigJson serializes the address + advanced fields into a
  structured JSON object using a Dictionary<string, object?>. Fields with
  default / empty values are omitted to keep diffs in the existing draft-diff
  viewer minimal — a tag with only an address still produces
  `{"addressString":"40001:F"}` and not a full superset object with nulls.
- OnAddressChanged + OnAdvancedChanged both delegate to RefreshTagConfigJson
  so any input change keeps TagConfig in sync.

Read-side hydration:

- New HydrateModbusFromTagConfig parses an existing TagConfig JSON and
  populates _modbusAddress + the three advanced fields. Falls back to empty
  defaults on malformed JSON. ResetAdvanced is called before hydration on
  every form open so leftover state from a previous edit doesn't leak.

ResetAdvanced helper introduced + called from StartAdd so a fresh "New tag"
form starts with everything cleared.

Tests (1 new in TagServiceTests):
- TagConfig_With_Advanced_Modbus_Fields_RoundTrips_Through_Factory — creates a
  tag whose TagConfig carries addressString + deadband + unitId +
  coalesceProhibited, persists via TagService, reloads, asserts every field
  survives. Then constructs a wrapping driver-config JSON and feeds it to
  ModbusDriverFactoryExtensions.CreateInstance — confirms the field NAMES the
  UI emits match what BuildTag's DTO consumes. If the UI's JSON shape ever
  drifts from the factory's expected DTO, this test catches it before users do.

119 + 1 = 120 Admin tests green. Solution build clean.
2026-04-25 04:22:50 -04:00
Joseph Doherty ec57df1009 Task #155 — TagService + TagsTab CRUD UI for Modbus tags
Closes the remaining loop on user-visible Modbus tag editing. Pre-#155 tags
arrived only via SQL seeding or runtime ITagDiscovery; the Admin UI had no
interactive surface for creating / editing / deleting tag rows.

Changes:

- TagService.cs (Admin/Services/) — CRUD wrapper around OtOpcUaConfigDbContext.Tags.
  ListAsync supports optional driver / equipment filters; CreateAsync auto-derives
  TagId; UpdateAsync persists editable fields; DeleteAsync removes the row. Mirrors
  the EquipmentService shape.
- TagsTab.razor (Components/Pages/Clusters/) — list + filter + add/edit/remove form.
  The address/config editor is conditional: when the selected DriverInstance is
  Modbus, ModbusAddressEditor (#145) renders with live-parse preview; otherwise a
  generic JSON textarea (matches the DriversTab pattern from #147). Save-side
  serializes the address-string into TagConfig as `{"addressString":"..."}` JSON.
- ClusterDetail.razor — new "Tags" tab in the cluster-detail nav strip + the routing
  switch.
- Program.cs — TagService registered as a scoped DI service.

Drive-by fix: ModbusDriverFactoryExtensions.CreateInstance promoted from internal
to public — Admin.Tests was using it via reflection-friendly internal access that
broke under the #153 logger overload addition. Public is the right access modifier
anyway since the Server-side bootstrapper calls it from a different assembly.

Drive-by fix #2: ModbusDriverConfigDto was missing MaxReadGap (#143) — surfaced by
the #147 round-trip test that flips MaxReadGap=12 in the view model and asserts
it lands on the resolved options. Added the field + binding line. Confirms #143's
DriverConfig JSON binding was incomplete since the original commit; no production
deployment configured this knob through JSON until now so the gap stayed hidden.

Tests (4 new TagServiceTests):
- Create_And_List_Surfaces_The_Tag — CreateAsync auto-assigns TagId; list returns
  the row.
- List_Filters_By_DriverInstance — driver-scoped filter works.
- Update_Persists_Editable_Fields — Name / DataType / AccessLevel / TagConfig all
  persist through Update.
- Delete_Removes_The_Row — basic delete verification.

113 + 4 (TagService) + 2 (DriversTab round-trip restored after compile fix) = 119
Admin tests green. Solution build clean.

Caveat: bUnit-style render tests for TagsTab still aren't included — Admin.Tests
doesn't have bUnit set up. The TagService logic is fully covered; the razor
component's parser/save glue is exercised by hand at runtime for now.
2026-04-25 01:51:02 -04:00
Joseph Doherty 802366c2c6 Task #154 — driver-diagnostics RPC: HTTP endpoint + Admin client
Foundation for surfacing per-driver runtime state from the Server process to
the Admin UI. #152 shipped GetAutoProhibitedRanges() as an in-process
accessor; #154 makes it reachable across processes.

Server side (HealthEndpointsHost):
- New URL family: /diagnostics/drivers/{driverInstanceId}/{driverType}/{topic}
- First wired topic: /diagnostics/drivers/{id}/modbus/auto-prohibited
- Driver-agnostic at the URL level — future driver types add their own
  segments[3] cases (e.g. /diagnostics/drivers/{id}/s7/dropped-pdus).
- 404 when the driver instance doesn't exist; 400 when the driver exists
  but isn't a Modbus driver (the per-type endpoint is wrong for this row).
- Response shape is flat JSON (unitId / region / startAddress / endAddress /
  lastProbedUtc / bisectionPending) so consumers don't have to reference the
  Driver.Modbus assembly's ModbusAutoProhibition record.
- Re-uses the existing HttpListener bound to localhost:4841 — same auth /
  reachability story as /healthz and /readyz.

Admin side:
- DriverDiagnosticsClient (Services/) — HttpClient wrapper that fetches the
  per-driver Modbus prohibition list. Returns null on 404/400 (driver
  missing or wrong type); throws on transport failures.
- ModbusAutoProhibitionsResponse + ModbusAutoProhibitionRow flat DTOs —
  client doesn't take a dep on Driver.Modbus.
- ModbusDiagnostics.razor at /modbus/diagnostics/{driverInstanceId} —
  table view with BISECTING (warning yellow) / ISOLATED (danger red)
  badges, relative timestamps (e.g. "5m ago"), Refresh button. Errors
  surface inline rather than swallowing.
- HttpClient registration in Program.cs reads
  DriverDiagnostics:ServerBaseUrl from appsettings.json (default
  http://localhost:4841/ for same-host deployments).

Tests (3 new in HealthEndpointsHostTests):
- Diagnostics_ReturnsModbusAutoProhibitions_ForLiveDriver — registers a
  Modbus driver with a programmable transport that protects register 102,
  records the prohibition via a coalesced ReadAsync, hits the endpoint,
  asserts the returned JSON matches (unitId / region / start / end / pending).
- Diagnostics_404_When_Driver_Not_Found
- Diagnostics_400_When_Driver_Is_Wrong_Type

Architecture note: the Admin-side bUnit-style component test isn't included
because Admin.Tests doesn't have bUnit set up. The DriverDiagnosticsClient
is unit-testable on its own with a mock HandlerStub if needed — left as a
follow-up alongside the broader bUnit setup task.

The diagnostic page is now reachable at /modbus/diagnostics/{driverId} from
any Admin instance pointing at a Server endpoint URL. Future driver types
(S7, AbCip) plug into the same channel by adding their own URL segments
in HealthEndpointsHost.WriteDriverDiagnosticsAsync.
2026-04-25 01:32:21 -04:00
Joseph Doherty 8004394892 Task #153 — ModbusDriver: inject ILogger so prohibition events reach a sink
#152 left a hook for structured logging when an auto-prohibition first
fires; this commit completes the wiring.

Changes:
- ModbusDriver constructor takes an optional ILogger<ModbusDriver> (defaults
  to NullLogger). Existing standalone callers stay compile-clean.
- RecordAutoProhibition logs LogWarning on first-fire only (re-fires of the
  same range stay quiet via the existing isNew de-dupe). Format includes
  DriverInstanceId, UnitId, Region, Start, End, Span — log aggregators can
  filter / count by any field.
- New LogProhibitionCleared helper called by both StraightReprobeAsync (when
  the re-probe succeeds on a single-register range) and BisectAndReprobeAsync
  (per-half clearing + a single combined line when both halves succeed).
- ModbusDriverFactoryExtensions.Register accepts an optional ILoggerFactory.
  Captured at registration time and used in the factory closure to construct
  a per-driver logger. Server bootstrap code that already has an ILoggerFactory
  in DI threads it through with a single argument addition; old call sites
  (Register(registry)) keep working with a null logger.

Tests (2 new ModbusLoggerInjectionTests):
- First_Failure_Emits_Single_Warning_Subsequent_Refire_Stays_Quiet — pins
  the de-dupe behaviour. First scan logs one warning with the expected
  structured fields; second scan with the same prohibition stays silent.
- Reprobe_Clearing_Prohibition_Emits_Information_Log — protected register
  unlocked between record and re-probe; re-probe success emits an info log
  containing "cleared".

CapturingLogger test harness is purpose-built (xUnit doesn't ship a logger
mock by default and adding Moq is overkill for two tests).

240 + 2 = 242 unit tests green.
2026-04-25 01:26:20 -04:00
Joseph Doherty b8df230eb8 Task #152 — Modbus coalescing: surface auto-prohibitions through diagnostics
Auto-prohibited ranges (#148) were previously visible only through an
internal AutoProhibitedRangeCount accessor used by tests. Production
operators had no way to see what the planner had learned without pulling
logs or inspecting driver state.

Changes:

- New public record `ModbusAutoProhibition(UnitId, Region, StartAddress,
  EndAddress, LastProbedUtc, BisectionPending)` — operator-facing snapshot
  shape. Lives in the addressing assembly's logical namespace alongside
  the other public types.
- `ModbusDriver.GetAutoProhibitedRanges()` returns
  `IReadOnlyList<ModbusAutoProhibition>` — a copy of the live prohibition
  map. Lock-protected snapshot so consumers don't race with the re-probe
  loop.
- RecordAutoProhibition tracks first-fire vs re-fire via the dictionary
  insert path, leaving a hook to add structured logging once an ILogger
  is plumbed through (currently elided to keep the constructor minimal
  for testability — a future change can wire ILogger and emit a single
  warning per first-fire).

Tests (1 new, additive to the 6 in ModbusCoalescingAutoRecoveryTests):
- GetAutoProhibitedRanges_Surfaces_Operator_Visible_Snapshot — confirms
  the snapshot shape: empty before any failure, populated with correct
  UnitId/Region/Start/End/BisectionPending after a failed coalesced read,
  LastProbedUtc within the recent past.

Docs:
- docs/v2/modbus-addressing.md — new "Coalescing auto-recovery" subsection
  consolidates the #148/#150/#151/#152 surface in one place. Documents
  the diagnostic accessor + flags the in-process consumption pattern
  (Server health endpoints today; Admin UI when an RPC channel exists).

239 + 1 = 240 unit tests green.

Caveat: the Admin UI surfacing (table render, "clear all prohibitions"
button) is intentionally NOT shipped here. Admin can't reach a live
ModbusDriver instance without a driver-diagnostics RPC channel that
doesn't exist yet — that's a larger architectural piece. For now the
data is queryable in-process by the Server's health endpoints; once an
RPC channel lands, Admin can wire the existing GetAutoProhibitedRanges
into a Blazor table without further driver changes.
2026-04-25 01:19:10 -04:00
Joseph Doherty f823c81c96 Task #150 — Modbus coalescing: bisection-style range narrowing
Pre-#150 a coalesced read failure recorded the FULL failed range as
permanently prohibited. Healthy registers around the actual protected
register stayed in per-tag mode forever (until ReinitializeAsync). The
re-probe loop shipped in #151 retried the whole range as a single block,
which would either succeed (clearing everything) or fail (changing
nothing).

Post-#150 the re-probe loop bisects multi-register prohibitions:

- _autoProhibited refactored from Dictionary<key, DateTime> to
  Dictionary<key, ProhibitionState> where ProhibitionState carries
  LastProbedUtc + SplitPending. Multi-register prohibitions enter with
  SplitPending=true; single-register prohibitions enter with
  SplitPending=false (already minimal).
- ReprobeLoopAsync delegates the per-pass work to
  RunReprobeOnceForTestAsync (also exposed for synchronous test driving).
  Each entry routes to BisectAndReprobeAsync (split-pending + multi-reg)
  or StraightReprobeAsync (single-reg / non-split-pending).
- Bisection: split (start, end) at mid = (start+end)/2. Try (start, mid)
  and (mid+1, end) as separate coalesced reads. Each FAILED half re-enters
  the prohibition map with SplitPending = (its end > its start). SUCCEEDED
  halves vanish, freeing the planner to coalesce across them on the next
  scan.
- Convergence: log2(span) re-probe ticks pin the prohibition to the
  actual single offending register(s). For a 100-register block with one
  protected address that's ~7 ticks.

Tests (3 new ModbusCoalescingBisectionTests):
- Bisection_Narrows_Multi_Register_Prohibition_Per_Reprobe — 11 tags
  100..110 with protected address 105. After 4 re-probe passes the
  prohibition collapses from (100..110) → (100..105) → (103..105) →
  (105..105).
- Bisection_Clears_When_Both_Halves_Are_Healthy — transient failure
  scenario; protection lifted before re-probe; both bisection halves
  succeed and the parent vanishes entirely.
- Bisection_Splits_Into_Two_When_Both_Halves_Still_Fail — TwoHoleTransport
  with protected addresses 102 + 108 in the same coalesced range. After
  bisection both halves still fail (each contains one of the protected
  addresses); the prohibition map grows to 2 entries.

236 + 3 = 239 unit tests green. Solution build clean.
2026-04-25 01:16:09 -04:00
68 changed files with 6296 additions and 185 deletions
+25
View File
@@ -169,6 +169,31 @@ Beyond per-tag addressing, `ModbusDriverOptions` exposes (#139#143):
bridge between adjacent register tags. With `MaxReadGap=10`, three tags
at HR 100/102/110 collapse into one FC03 of quantity 11.
### Coalescing auto-recovery (#148 / #150 / #151 / #152)
- A coalesced read that fails with a Modbus exception (write-only or
protected register mid-block) records the failed range as
auto-prohibited. The planner stops re-coalescing across the range; the
per-tag fallback path keeps healthy members working in the same scan.
- **Bisection (#150)**: every re-probe pass narrows multi-register
prohibitions by trying the two halves separately. Over log2(span)
ticks the prohibition pins at the actual offending register(s);
intermediate halves that succeed get cleared.
- **Periodic re-probe (#151)**: opt in via
`AutoProhibitReprobeInterval` (TimeSpan?). Default null = disabled
(prohibitions persist for the driver lifetime; clear on
`ReinitializeAsync`).
- **Per-tag escape hatch**: `CoalesceProhibited` (bool, default false)
on `ModbusTagDefinition`. The planner reads such tags in isolation
regardless of `MaxReadGap`. Use for known-bad addresses you want to
exclude from the auto-discovery loop.
- **Diagnostics (#152)**: `ModbusDriver.GetAutoProhibitedRanges()`
returns a snapshot of every active prohibition as
`ModbusAutoProhibition` records (UnitId / Region / StartAddress /
EndAddress / LastProbedUtc / BisectionPending). Surface in the
driver-diagnostics RPC channel when that wiring lands; for now
consumable by in-process callers (Server health endpoints, log
aggregation).
## JSON DTO shape
The factory accepts both the structured form (legacy) and the new
+274
View File
@@ -0,0 +1,274 @@
# Galaxy / LMX Backend — Restructuring Options
## Context
Today the Galaxy driver is structured very differently from every other driver
in this repo:
- **Galaxy.Proxy** (.NET 10, in-process): tiny shim that frames IPC to the host.
- **Galaxy.Host** (.NET Framework 4.8 **x86**, NSSM-wrapped Windows service):
owns MXAccess COM, the STA pump, the ZB Galaxy Repository SQL queries, the
Wonderware Historian SDK plugin, the per-platform `ScanState` probe manager,
the alarm tracker (`.InAlarm`/`.Priority`/`.DescAttrName`/`.Acked` state
machine + ack writer), recycle policy, and post-mortem MMF.
Other drivers (Modbus, S7, AB CIP, OpcUaClient, TwinCAT, FOCAS Tier-C) are
**in-process Tier-A drivers** in the .NET 10 server. They do data + browse
only; historian and alarming are driver-agnostic concerns at the server layer.
A sibling project, **mxaccessgw**
(`C:\Users\dohertj2\Desktop\mxaccessgw`), already provides:
- A .NET 10 x64 gRPC gateway in front of per-session .NET 4.8 x86 worker
processes that own MXAccess COM, the STA, and event sinks
(`MxGateway.Server` + `MxGateway.Worker`).
- A full MXAccess command + event surface (`Register`, `AddItem`, `Advise`,
`Write`, `WriteSecured`, `OnDataChange`, `OnWriteComplete`, etc.).
- A cached, deploy-gated, paged **Galaxy Repository browse** RPC
(`galaxy_repository.v1`) reading the same ZB tables we read today, with the
query bodies kept byte-identical to OtOpcUa.
- A .NET client library (`clients/dotnet/MxGateway.Client`).
- API-key auth, Blazor dashboard, structured logs, metrics, watchdog/recycle.
The proposal is to **strip Galaxy down to data + browse** — push historian and
alarming out to server-level subsystems where they live for every other driver
— and pick how the slimmed-down driver talks to MXAccess.
---
## What "push historian and alarming out" means
Both options below assume the same scope reduction; they only differ in how
the driver reaches MXAccess.
| Concern | Today (Galaxy.Host) | After |
|---|---|---|
| Galaxy hierarchy browse | `GalaxyRepository` (SQL) inside Host | Driver (Option 1: via gw browse RPC; Option 2: own SQL or worker) |
| Live read / write / subscribe | `MxAccessClient` + STA pump in Host | gw (Option 1) or embedded worker (Option 2) |
| Wonderware Historian SDK | `HistorianDataSource` in Host (x86) | Separate Historian data source plugged into the server's HA service. Likely stays its own .NET 4.8 x86 sidecar because the SDK is x86-only; **independent of the Galaxy driver lifecycle**. |
| Alarm state machine (`.InAlarm`/`.Acked` quartet, transitions, ack writer) | `GalaxyAlarmTracker` in Host | Server-level A&E subsystem subscribes to alarm-bearing attributes the driver advertises and runs the AlarmCondition state machine generically. Driver only flags `IsAlarm=true` in node metadata. |
| `ScanState` per-platform probes | `GalaxyRuntimeProbeManager` in Host | Driver-side: ScanState is just another tag subscription; the driver re-advises one per discovered `$WinPlatform`/`$AppEngine` and reports `HostConnectivityStatus` from the value stream. No special host-side machinery. |
After the strip-down, the Galaxy driver looks like Modbus or OpcUaClient: it
discovers nodes, reads/writes/subscribes, and reports per-host transport
health. Everything else is the server's problem.
---
## Option 1 — Tier-A driver against the MxAccess Gateway
`Driver.Galaxy` becomes a regular **in-process .NET 10 driver** in the OtOpcUa
server (no `.Host`, no `.Proxy` split, no x86). It talks to a separately
deployed `MxGateway.Server` over gRPC using `MxGateway.Client`. Browse comes
from `galaxy_repository.v1.DiscoverHierarchy`. Live data comes from
`MxAccessGateway.OpenSession`/`AddItem`/`Advise`/`StreamEvents`.
```
OtOpcUa.Server (.NET 10 x64)
└── Driver.Galaxy (in-proc, .NET 10)
└── gRPC ──► MxGateway.Server (.NET 10 x64)
└── pipe ──► MxGateway.Worker (.NET 4.8 x86)
└── MXAccess COM (STA)
```
### Pros
- **Architectural parity with other drivers.** No bespoke `Host` service, no
x86 build target, no NSSM wrapper, no STA pump in this repo, no
`PostMortemMmf`/`RecyclePolicy` we maintain ourselves.
- **OtOpcUa server stops needing AVEVA installed on its own host.** The
gateway runs where MXAccess lives; the OPC UA server can live on a different
box, in a container, or on a hardened jump host.
- **One canonical MXAccess surface across the org.** Any future tool — a
diagnostic CLI, a Historian replacement, an integration harness — talks to
the same gw with the same parity guarantees we get.
- **Multi-instance friendly.** Two OtOpcUa servers (warm/hot redundancy) share
one gw and one MXAccess footprint instead of each running their own
`Galaxy.Host` with duplicate Wonderware client identities.
- **Browse + cache for free.** `galaxy_repository.v1` already implements the
hierarchy cache, deploy-time gating, paging, and `WatchDeployEvents` — we
delete `GalaxyRepository.cs`, `GalaxyHierarchyRow.cs`, the change-detection
poll loop, and the matching SQL plumbing.
- **Operability for free.** API-key auth, Blazor dashboard at `/dashboard`,
metrics via `Meter`, structured logs with redaction. We currently have
none of that in `Galaxy.Host`.
- **Future backend swap.** When AVEVA exposes managed NMX or another modern
path, gw routes to it without OtOpcUa changes (gw's stated roadmap).
- **Tighter blast radius.** A hung COM event, a leaking COM object, a
crashing worker — all owned by gw's session/worker isolation, not the
OPC UA server process.
- **Simpler version story for OtOpcUa.** Driver is plain .NET 10; the
bitness/runtime split lives entirely in mxaccessgw's repo.
### Cons
- **Extra deployment dependency.** mxaccessgw is now a service that has to be
installed, monitored, and kept on a compatible protocol version. For a
single-box install this is one more moving piece.
- **Two hops on every call** (driver→gw, gw→worker) instead of one
(proxy→host). Today's hop is MessagePack over a named pipe; the new outer
hop is gRPC over TCP. Per-call overhead is a few hundred microseconds, not
a regression for OPC UA workloads but measurable for very chatty bursts.
- **Auth/secret surface added.** OtOpcUa now holds an API key for gw and
rotates it; gw's SQLite-backed key store has to be managed.
- **Failure model spans two processes we don't own** — gw + worker. Reconnect
logic in our driver has to ride both: gw transport drop, gw session lease
expiry, gw-detected worker crash, plus the worker's own MXAccess reconnect.
All of it is exposed in the gRPC contract, but it's still surface area.
- **Cross-repo protocol coupling.** Bumping `mxaccessgw` major version (gRPC
contract changes, session shape changes) ripples into OtOpcUa releases.
Mitigated by versioned contracts; not free.
- **Galaxy redundancy still has to think about gw.** A redundancy fail-over of
OtOpcUa is independent of the gw's session lifecycle. Need to decide whether
the standby holds an open session or only opens it on takeover.
- **Sensitive writes (`WriteSecured`, `AuthenticateUser`) cross the network**
if gw is remote. TLS + mTLS solves it but adds setup.
---
## Option 2 — Embed mxaccessgw worker, no gateway
`Driver.Galaxy` is still in-process .NET 10, but instead of speaking gRPC to a
gateway service, it directly **launches and supervises one (or more)
`MxGateway.Worker` processes** and talks to them over the same named-pipe
worker protocol gw uses internally
(`docs/WorkerFrameProtocol.md`, `docs/WorkerProcessLauncher.md`). Browse stays
local — driver runs the SQL queries against ZB itself.
```
OtOpcUa.Server (.NET 10 x64)
└── Driver.Galaxy (in-proc, .NET 10)
├── ZB SQL (local, in-proc)
└── pipe ──► MxGateway.Worker (.NET 4.8 x86, child process)
└── MXAccess COM (STA)
```
### Pros
- **One hop, not two.** Driver → worker pipe is the same shape as today's
Proxy → Host pipe. Latency is on par with the current implementation.
- **No new service to deploy.** Worker is launched as a child process the
same way `Galaxy.Host` is launched today (just with mxaccessgw's worker
binary). Single-machine install story stays simple.
- **Keeps the trust boundary local.** No API keys, no TLS, no exposed gRPC
port on the OtOpcUa box.
- **Reuses mxaccessgw's parity-tested worker code** — STA pump, COM lifetime,
event conversion, fault model — without inheriting gw's ASP.NET Core /
Blazor / SQLite footprint.
- **Tighter ownership.** OtOpcUa owns the worker lifecycle; recycle, kill,
restart, post-mortem all decided by the driver, not by an external service
we don't control.
- **Easier to reason about during integration tests.** No second service to
spin up in CI; just a child process per test fixture.
### Cons
- **OtOpcUa server box must still have AVEVA + MXAccess installed**, since
the worker runs locally. The major deployment win of Option 1
(separating where MXAccess runs from where OtOpcUa runs) is lost.
- **OtOpcUa still ships an x86 .NET 4.8 binary alongside it.** Even if we
vendor mxaccessgw's worker rather than write our own, installer complexity
and bitness considerations remain.
- **We re-implement everything gw already gives.** Process supervision,
watchdog, recycle policy, heartbeat, post-mortem — these are exactly what
`Galaxy.Host` does today, and they'd live in our repo again, just calling a
different worker binary.
- **No browse cache, no deploy gating, no `WatchDeployEvents`** — we keep
running our own ZB queries and our own `time_of_last_deploy` poll, or we
port gw's cache code into the driver. Either way it's duplicated logic.
- **No auth, no dashboard, no metrics.** Operability stays where it is today
(i.e., minimal). Adding it ourselves is a separate project.
- **Multiple OtOpcUa instances multiply MXAccess sessions.** Redundancy pair
→ two MXAccess clients on the Galaxy from the same software, vs. Option 1
where one gw arbitrates.
- **Worker protocol coupling without the contract surface.** We depend on
mxaccessgw's worker IPC frame format — a surface that mxaccessgw treats as
*internal* to its own gw↔worker boundary. If they refactor it, we have to
follow. The public gRPC contract (Option 1) is more stable by design.
- **Loses the "common MXAccess access point" benefit.** Other consumers
(CLI, integration harnesses, future tools) can't share state with our
embedded worker.
---
## Status quo (for comparison)
Keep `Galaxy.Host` as today, and in-place rip out historian + alarming +
probe manager. End state: the Host shrinks to `MxAccessClient` + `GalaxyRepository`,
which is roughly what Option 2 ends up looking like — but with our hand-rolled
COM bridge instead of mxaccessgw's worker. Not a serious option once
mxaccessgw exists; we'd be maintaining a parallel implementation of the same
thing.
---
## Recommendation (effort-agnostic)
**Go with Option 1 — Tier-A driver against the MxAccess Gateway.**
The decisive arguments:
1. **It's the only option that aligns Galaxy with how every other driver in
this repo is structured.** The user's stated goal — "keep lmx to data +
browsing, similar to other drivers" — only fully resolves if there is no
`.Host` and no x86 build artifact in this repo at all. Option 2 still has
an x86 child process and supervisor code; it's `Galaxy.Host` with a
different worker binary inside.
2. **It separates *where MXAccess runs* from *where OtOpcUa runs*.** That is
a strategically larger win than a few hundred microseconds of per-call
latency. The OPC UA server stops being chained to AVEVA install footprint,
bitness, and Wonderware client identity — which removes a class of
deployment, redundancy, and CI problems we hit today (e.g., the
`DESKTOP-6JL3KKO` Hyper-V/Docker conflict, the `dohertj2`-only pipe ACL,
the live-Galaxy smoke test prerequisites).
3. **It collapses scope.** A non-trivial fraction of `Galaxy.Host` (browse
cache, deploy-event watch, worker supervision, COM bridge, post-mortem,
recycle, ACL hardening) is reproduced *better* in mxaccessgw. Option 1
deletes our copy. Option 2 keeps it.
4. **It positions historian and alarming for the right home.** Once the
Galaxy driver is "just another driver", historian becomes a server-level
data source (one that can also feed Modbus/S7 history if we ever want it),
and alarming becomes a server-level A&E subsystem. Option 2 nominally
allows the same move, but the temptation to keep them in `Galaxy.Host`
"while we're already there" is real.
5. **It future-proofs against AVEVA's roadmap.** Managed NMX, ASB, or any
replacement that shows up over the next few years gets adopted in
mxaccessgw without a release in this repo.
The case for Option 2 is real but narrow: it's the right call **only** if we
commit to single-box deployments forever, refuse to take a gRPC dependency,
and value local-trust simplicity over the consolidation/operability benefits
gw provides. None of those constraints hold here.
### What flips the recommendation
- If the gw protocol is unstable or perf-tested under our subscription
patterns turns out worse than expected → revisit Option 2.
- If org-policy forbids running an MXAccess gateway as its own service →
Option 2.
- If Galaxy goes from one of several drivers to *the* primary driver and
raw call-rate matters more than architectural fit → revisit.
Otherwise: Option 1.
---
## Out-of-scope follow-ups (don't decide here, but flag them)
- **Where does the Wonderware Historian SDK live?** Likely its own
.NET 4.8 x86 sidecar exposing a small `IHistorianDataSource` over a pipe or
gRPC, plugged into the OPC UA server's HA service alongside any future
historian sources. Independent of which option above is chosen.
- **Alarm subsystem ownership.** Decide whether the server hosts a generic
AlarmCondition state machine driven by driver-advertised alarm metadata, or
whether each driver continues to emit pre-shaped alarm transitions. Galaxy's
4-attr quartet is a strong forcing function for the generic approach.
- **Redundancy + gw sessions.** Standby OtOpcUa holds an open gw session
(warm) vs. opens on takeover (cold). Affects gw worker count and Galaxy
client-identity collisions.
- **Auth between OtOpcUa and gw.** API key in DPAPI-protected secret file vs.
Windows-auth gRPC. Both supported by gw; pick before rollout.
+476
View File
@@ -0,0 +1,476 @@
# Galaxy → MxAccessGateway Migration Plan
Implements **Option 1** from `lmx_backend.md`: replace the bespoke `Galaxy.Host`
+ `Galaxy.Proxy` IPC pair with an **in-process Tier-A** `Driver.Galaxy` running
in the .NET 10 OtOpcUa server, talking to a separately-deployed
`MxGateway.Server` (mxaccessgw repo) over gRPC for live MXAccess work and
Galaxy Repository browse.
## Outcome
After this work:
- `OtOpcUa.Server` is fully .NET 10 x64 — no x86 build artifacts in this repo.
- `Driver.Galaxy.Host` (Windows service, NSSM-wrapped, .NET 4.8 x86) is
retired. `Driver.Galaxy.Proxy` and `Driver.Galaxy.Shared` are deleted.
AVEVA platform is no longer required on the OtOpcUa box.
- A new in-process `Driver.Galaxy` lives next to `Driver.Modbus`,
`Driver.OpcUaClient`, etc. It implements the same `IDriver` capability set
the proxy implements today, but its body calls `MxGateway.Client`
(`MxGatewayClient`, `MxGatewaySession`, `GalaxyRepositoryClient`).
- Wonderware Historian SDK access moves out of the Galaxy driver into a
driver-agnostic historian data source (`Driver.Historian.Wonderware`,
separate sidecar, .NET 4.8 x86). The OPC UA HA service plugs into it the
same way it would plug into any future historian.
- Alarm condition tracking moves out of the driver into the OPC UA server's
generic A&E subsystem. The driver only flags `IsAlarm=true` on attribute
metadata and forwards live `.InAlarm`/`.Acked`/etc value changes; the
server runs the AlarmCondition state machine.
- Per-platform `ScanState` probes degrade to plain attribute subscriptions —
no special probe manager.
---
## Pre-flight: improvements to land in mxaccessgw first
These are **integration-quality changes** in the mxaccessgw repo that make
the OtOpcUa side dramatically simpler / faster / more robust. They aren't
strictly required to start, but ship enough of them before phase 3 that we're
not designing around gaps.
### gw-1. Galaxy attribute metadata parity
**What's there:** `galaxy_repository.v1.DiscoverHierarchy` returns
`GalaxyObject` with name, parent, category, and dynamic attributes.
**What's missing for OtOpcUa:** every field today's `MxAccessGalaxyBackend`
copies into `GalaxyAttributeInfo` — confirm gw's `Attribute` proto carries:
- `mx_data_type` (int)
- `is_array` (bool)
- `array_dimension` (uint, optional)
- `security_classification` (int)
- `is_historized` (bool, from `HistorizedExtension` primitive)
- `is_alarm` (bool, from `AlarmExtension` primitive)
If any are missing, add them to the proto and the server-side query mapper.
Without `IsAlarm` and `IsHistorized` the OPC UA server can't decide which
nodes get HasHistoricalConfiguration / which become AlarmConditions.
### gw-2. Stable, documented event-stream resume semantics
**What's needed:** the OtOpcUa driver must survive a transient gw transport
drop without losing subscription state or duplicating change events. gw's
`StreamEventsAsync(afterWorkerSequence)` already exposes resumption.
Document the per-session retention window (how long does the worker buffer
events the gateway hasn't acked?) and the "events were dropped, you must
re-subscribe" signal. If retention is bounded by count rather than time,
expose the bound in `OpenSessionReply` so the client can size its own buffer.
### gw-3. Reconnectable sessions
Listed under "post-v1 revisit" in `gateway.md`. Without it, every gw or
OtOpcUa restart re-`Register`s, re-`AddItem`s, re-`Advise`s the entire
address space — for a 50k-tag Galaxy that's a non-trivial cold-start. With
reconnectable sessions, the driver presents its `SessionId` after a restart
and the worker keeps its handles.
If full reconnection is too large, ship a **bulk replay** instead: a single
RPC that takes the full subscription set and the worker performs the
register/add/advise inside one round trip. We can drive it from a
client-side cache rather than gw state. See gw-5 below.
### gw-4. Driver-shaped subscribe primitive
`MxGatewaySession` already has `SubscribeBulkAsync` (one RPC: `Register`
implicit + `AddItem` + `Advise` for a list of tag addresses, returning
per-tag `SubscribeResult`). That's exactly what `ISubscribable.SubscribeAsync`
wants. Confirm it returns enough per-tag detail to surface a partial-failure
list to OPC UA monitored items (good handle, status code, error text).
If not already, expose **`SubscribeBulk` with optional update-rate hint**
forwarded to `SetBufferedUpdateInterval` so the OPC UA publishing interval
becomes a single field on the subscribe call rather than a follow-up RPC.
### gw-5. Subscription replay snapshot
Provide an RPC `ReplaySubscriptionsAsync(SessionId, IEnumerable<TagAddress>)`
that re-establishes a list of subscriptions after a session reset and returns
per-tag results. The client stores its tag list locally (the driver already
has it from `Discover`), and the gw worker turns it into one
register/add/advise sequence. This is the minimum surface we need; full
"reattach to a previous session by id" (gw-3) is a richer version of the
same thing.
### gw-6. Transport-health stream
The gw already exposes worker / session health on its dashboard. Add a small
streaming RPC `StreamSessionHealth(SessionId) → stream SessionHealth` so the
OtOpcUa driver can surface "MXAccess transport up/down" to its
`IHostConnectivityProbe` without faking it via probe-tag subscriptions.
Today `MxAccessClient.ConnectionStateChanged` does this in-process; we want
the same signal at the gw boundary.
### gw-7. Optional .NET 10 client polish
- Async-disposable session pattern is already there.
- Add a **typed `MxValue` ⇄ `object` adapter** for the seven Galaxy types
OtOpcUa cares about (Boolean, Int32, Float, Double, String, DateTime,
arrays of the same). Today every consumer writes its own `MxValue.From<T>`
helpers; this shaves boilerplate from the driver.
- Add a **`SubscribeWithCallback`** convenience wrapper that combines
`OpenSession` + `SubscribeBulk` + `StreamEvents` and routes events through
a delegate per tag. Keeps the OPC UA driver from re-implementing the
fan-out / sequencer pattern.
### gw-8. Auth minimums
Document API-key scoping as it applies to OtOpcUa: the server identity needs
`session`, `invoke`, `event`, and `metadata:read` scopes. Provide a CLI to
mint a key bound to those scopes for an OtOpcUa instance.
### gw-9. Performance: bulk paths and value coalescing
- Confirm `SubscribeBulkAsync` is implemented as a single MXAccess
`AddItem`+`Advise` loop on the worker, not N pipe round trips. If not, fix
before we drive 50k-tag Galaxies through it.
- Expose `SetBufferedUpdateInterval` per session so OtOpcUa can request
buffered updates at the OPC UA publishing interval and get one batched
`OnBufferedDataChange` per tick rather than N `OnDataChange` events.
These can all ship in mxaccessgw independently and improve every consumer.
---
## OtOpcUa-side improvements to land in parallel
Some are forced by removing `Galaxy.Host`; others are quality-of-life.
### ot-1. Promote `IHistorianDataSource` to a server-level extension point
Today `IHistorianDataSource` is a Galaxy-internal abstraction in
`Driver.Galaxy.Host`. Lift it to `OtOpcUa.Core.Abstractions` (or a similar
home next to `IDriver`) and let the OPC UA HA service consume **any number
of registered data sources** keyed by node namespace. Drivers don't own
historian access; the server mounts data sources alongside drivers. This is
the prerequisite that lets us move Wonderware Historian out of the Galaxy
driver without losing the feature.
### ot-2. Generic alarm condition state machine in the server
Move the `.InAlarm`/`.Priority`/`.DescAttrName`/`.Acked` quartet handling
out of `GalaxyAlarmTracker` into a server-level alarm subsystem keyed off the
`IsAlarm=true` flag drivers set during discovery. The server subscribes to
the four sub-attributes itself and runs the AlarmCondition state machine.
Driver only:
- declares `IsAlarm=true` in `DriverAttributeInfo`,
- forwards plain attribute value changes (already done by `ISubscribable`).
This is also a precondition for future drivers (Modbus DL205 alarm bits,
S7 alarm DBs) to emit alarms without each writing their own tracker.
### ot-3. Driver capabilities trim
After ot-1 and ot-2, `Driver.Galaxy` no longer needs to implement:
- `IHistoryProvider` (server's HA service handles it via Wonderware
historian data source)
- `IAlarmHistorianWriter` (server's A&E historian, or kept generic — Galaxy
shouldn't own the SQLite path)
- `IAlarmSource` ack route (server-level alarm subsystem writes back via the
driver's `IWritable.WriteAsync`, which the gw already supports)
Keep:
- `IDriver`, `ITagDiscovery`, `IReadable`, `IWritable`, `ISubscribable`,
`IRediscoverable`, `IHostConnectivityProbe`.
### ot-4. Treat `time_of_last_deploy` as `IRediscoverable`'s pump
Replace the Host-side change-detection poll with a managed
`GalaxyRepositoryClient.WatchDeployEventsAsync` consumer in the driver.
Each event raises `OnRediscoveryNeeded` with the new deploy time as the
`scopeHint`. No polling code in this repo.
### ot-5. Connection pool at the server, not the driver
If the redundancy pair runs two OtOpcUa instances against one gw, both
should share a single `GrpcChannel` per process (already gRPC default) but
**different sessions** (one MXAccess client identity per OtOpcUa instance,
not one shared session that fights over Wonderware client state). Encode
the per-instance MXAccess client name in driver config — already partly
there (`OTOPCUA_GALAXY_CLIENT_NAME`); make it explicit in the new driver's
`appsettings.json` shape.
---
## Phased implementation
Each phase is a working, mergeable slice. Keep `Galaxy.Host` running
alongside the new driver until phase 7 — gated by a config switch
`Galaxy:Backend = legacy-host | mxgateway`.
### Phase 0 — pre-flight (mxaccessgw repo)
Ship gw-1, gw-2, gw-4, gw-9 (the parity, performance, and contract bits the
plan immediately depends on). gw-3, gw-5, gw-6, gw-7 can come during or
after phase 5.
**Exit:** local OtOpcUa dev box can `MxGatewayClient.Create` a client, open a
session, `SubscribeBulkAsync` 100 tags, and observe `OnDataChange` events at
the configured update rate.
### Phase 1 — server-level historian extension point (ot-1)
1. Extract `IHistorianDataSource` (and its DTOs `HistorianSample`,
`HistorianAggregateSample`, `HistoricalEvent`) from
`Driver.Galaxy.Host/Backend/Historian/` into
`src/ZB.MOM.WW.OtOpcUa.Core/Abstractions/Historian/`.
2. Extend the OPC UA HA service to look up a registered
`IHistorianDataSource` per namespace and call into it for `HistoryRead`,
`HistoryReadProcessed`, `HistoryReadAtTime`, `HistoryReadEvents`. Drivers
stop implementing `IHistoryProvider` directly; the server proxies.
3. Add a no-op default registration so drivers without history keep working.
**Exit:** all current Galaxy history reads route through an
`IHistorianDataSource` registered by `Driver.Galaxy.Host` (still legacy)
without behavior change. Other drivers untouched.
### Phase 2 — server-level alarm subsystem (ot-2)
1. Add an `IAlarmConditionDeclaration` API on the address-space builder so
discovery can flag a node as alarm-bearing and supply the four
sub-attribute references.
2. Add a hosted `AlarmConditionService` in the server that, on driver
`Discover`, subscribes to the four sub-attributes via the driver's own
`ISubscribable`, runs the state machine, and emits
`IAlarmSource.OnAlarmEvent` itself. Acks route back through the driver's
`IWritable.WriteAsync` to the `.AckMsg` attribute.
3. Add Galaxy-specific defaults (sub-attribute naming) as a small adapter
so the same service can serve future drivers with different conventions.
**Exit:** Galaxy alarms still work end-to-end; the tracker code that runs
inside `Galaxy.Host` is dead but kept for the legacy-host backend path.
### Phase 3 — Wonderware Historian sidecar (`Driver.Historian.Wonderware`)
1. New solution project: `Driver.Historian.Wonderware`, .NET 4.8 x86,
console app + NSSM (mirrors today's Galaxy.Host packaging exactly,
minus Galaxy responsibilities).
2. Hosts the existing `HistorianDataSource`, `HistorianClusterEndpointPicker`,
`HistorianHealthSnapshot` code lifted from `Galaxy.Host/Backend/Historian/`
and exposes them over a small named-pipe protocol (or local gRPC if
.NET 4.8 cost is acceptable; named pipe is simpler).
3. Add `Driver.Historian.Wonderware.Client` — .NET 10 — implementing
`IHistorianDataSource` against the sidecar.
4. Server registers it as a data source for the `Galaxy` namespace.
**Exit:** OPC UA history reads work via the sidecar with the legacy-host
backend still in place. We've decoupled history from MXAccess.
### Phase 4 — new `Driver.Galaxy` against gw
This is the meat. New project: `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/`, .NET 10,
in-process. Capabilities (post ot-3): `IDriver`, `ITagDiscovery`, `IReadable`,
`IWritable`, `ISubscribable`, `IRediscoverable`, `IHostConnectivityProbe`.
Shape:
```
Driver.Galaxy/
GalaxyDriver.cs # IDriver root
Browse/
GalaxyDiscoverer.cs # consumes GalaxyRepositoryClient.DiscoverHierarchyAsync
DataTypeMap.cs # mx_data_type → DriverDataType
SecurityMap.cs # security_classification → SecurityClassification
Runtime/
GalaxyMxSession.cs # owns one MxGatewaySession; Register + map per-driver client name
SubscriptionRegistry.cs # tag → server/item handles; persists to memory only
EventPump.cs # consumes session.StreamEventsAsync, fans out to OnDataChange
ReconnectSupervisor.cs # gw transport drop / session-lost recovery
DeployWatcher.cs # GalaxyRepositoryClient.WatchDeployEventsAsync → OnRediscoveryNeeded
Health/
HostConnectivityForwarder.cs # gw-6 SessionHealth → IHostConnectivityProbe
Config/
GalaxyDriverOptions.cs # endpoint, ApiKey, ClientName, TLS, retry, intervals
GalaxyDriverFactoryExtensions.cs # AddGalaxyDriver(IServiceCollection)
```
Key behaviors:
- **Discovery** calls `GalaxyRepositoryClient.DiscoverHierarchyAsync()`
once at init and on every `WatchDeployEvents` event, then drives the
address space builder. Same node naming as today (parent contained-name
hierarchy + leaf attributes named `tag_name.AttributeName`).
- **Read** uses one-off `AddItem` + `Advise` + read-after-first-callback
is overkill; instead, use **`Register` + per-call `AddItem`/`Read`** if gw
exposes a synchronous read, otherwise short-lived advise. *Action item:*
confirm gw's read story; if absent, request a synchronous `ReadAsync` RPC
on top of MXAccess `Read` (which exists in the COM API).
- **Write** maps `WriteRequest.Value` to `MxValue` via gw-7 helpers and
calls `WriteAsync(serverHandle, itemHandle, value, userId=0)`. Routes
`WriteSecured` (where `SecurityClassification == SecuredWrite/Verified`)
to `WriteSecuredAsync` once exposed on `MxGatewaySession`.
- **Subscribe** calls `SubscribeBulkAsync` once per `ISubscribable.Subscribe`
call. Stores `(tag → itemHandle, sid)` in `SubscriptionRegistry`. The
single `EventPump` consumes one `StreamEventsAsync` per session and fans
out per `sid`.
- **Unsubscribe** calls `UnsubscribeBulkAsync` and drops registry entries.
- **Reconnect** — when the gRPC channel drops or `StreamEvents` returns,
`ReconnectSupervisor` reopens the session and replays subscriptions via
gw-5 `ReplaySubscriptionsAsync`. The driver flags `DriverState.Degraded`
during recovery; the server keeps publishing last-good values with
`Uncertain` quality.
- **Host connectivity** — single synthesized host entry named after
`OTOPCUA_GALAXY_CLIENT_NAME` driven by gw-6 `SessionHealth` updates
(or, until gw-6 lands, by transport drops).
Wire into the server next to other Tier-A drivers in the
`AddDrivers(...)` call site.
**Exit:** flipping `Galaxy:Backend` to `mxgateway` runs the OPC UA server
end-to-end with no `Galaxy.Host` involvement. Live read, live write, live
subscribe pass against the dev Galaxy. Historian + alarms still work via
phases 13.
### Phase 5 — parity test matrix
Reuse the existing live-Galaxy integration tests; run each scenario twice:
once with `Galaxy:Backend=legacy-host`, once with `mxgateway`. Compare:
- discovered hierarchy node count + names + datatypes,
- subscribed publish rates (allow ±10% tolerance vs. legacy),
- write success / status codes for each `SecurityClassification`,
- alarm condition transitions (Active / Acked / Inactive) — already
routed through phase 2's server-level subsystem,
- history reads — phase 3 sidecar, identical results both backends,
- reconnect behavior under gw kill, worker kill, network drop, ZB drop.
Document the matrix; resolve every discrepancy or explicitly accept it.
**Exit:** parity matrix has zero unexplained deltas. Performance budget
agreed: e.g. ≤ 2× per-call latency vs. named-pipe baseline at the 95th
percentile, equal or better throughput in `SubscribeBulk` setup time.
### Phase 6 — perf + hardening
- Land gw-9 buffered-update intervals.
- Add OpenTelemetry traces from the driver around every gw call,
correlated via `client_correlation_id`.
- Write soak test: 50k tags subscribed, 24h, count missed events, gw
restarts, OtOpcUa restarts.
- Tune `MxGatewayClientOptions.MaxGrpcMessageBytes`, retry pipeline,
call timeouts based on soak results.
**Exit:** production-acceptable perf numbers documented in
`docs/Galaxy.Driver.md`.
### Phase 7 — retirement
1. Default `Galaxy:Backend = mxgateway` everywhere (sample configs,
install scripts, e2e configs).
2. Delete `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host`,
`src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy`,
`src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared`, and matching tests.
3. Remove `OtOpcUaGalaxyHost` NSSM registration from
`scripts/install/Install-Services.ps1`. Add a registration block for the
Wonderware historian sidecar from phase 3.
4. Remove every x86 .NET 4.8 reference, build target, and CI step from this
repo; remove `mxaccess_documentation.md`-driven dependencies that no
longer apply.
5. Update CLAUDE.md, `docs/v2/dev-environment.md`, `docs/ServiceHosting.md`,
`docs/Redundancy.md` to reflect the new topology.
6. Memory housekeeping: retire `project_galaxy_host_service.md` and
`project_galaxy_host_installed.md`; add a short note about the gw
dependency.
**Exit:** `git grep -i 'Galaxy\.Host'` returns nothing in source.
---
## Configuration shape (new driver)
```jsonc
"Drivers": {
"Galaxy": {
"Type": "Galaxy",
"InstanceId": "galaxy-prod-1",
"Gateway": {
"Endpoint": "https://mxgw.aveva.local:5001",
"ApiKeySecretRef": "galaxy:apiKey", // resolved via existing secret store
"UseTls": true,
"CaCertificatePath": "C:\\publish\\mxgw\\ca.crt",
"ConnectTimeoutSeconds": 10,
"DefaultCallTimeoutSeconds": 5,
"StreamTimeoutSeconds": 0 // unbounded
},
"MxAccess": {
"ClientName": "OtOpcUa-A", // unique per OtOpcUa instance
"PublishingIntervalMs": 1000, // hint for SetBufferedUpdateInterval
"WriteUserId": 0
},
"Repository": {
"DiscoverPageSize": 5000,
"WatchDeployEvents": true
},
"Reconnect": {
"InitialBackoffMs": 500,
"MaxBackoffMs": 30000,
"ReplayOnSessionLost": true
}
}
}
```
The OtOpcUa secret store already handles DPAPI-protected values for LDAP
binds; reuse it for the gw API key. Never put the key in plaintext in the
sample config.
---
## Risks and mitigations
| Risk | Mitigation |
|---|---|
| gw protocol regression breaks production | Pin gw NuGet to a contract version range; CI runs parity matrix on every gw bump; staged rollout via `Galaxy:Backend` flag. |
| Per-call latency regresses for chatty workloads | Land gw-9 (buffered updates) before phase 5; soak the 95p in phase 6. |
| Reconnect storm after gw restart re-registers 50k tags | Land gw-3 or gw-5 before phase 6; client-side bulk replay throttled by `SubscribeBulkAsync` chunk size. |
| Alarm parity gap from moving tracker server-side | Phase 2 ships before phase 4; parity matrix gates phase 7. |
| Historian sidecar adds a second .NET 4.8 x86 service | Acceptable: it's a *driver-agnostic* component, and it ships only where Wonderware historian access is actually needed. |
| Two OtOpcUa instances both registering as same MXAccess client | `ClientName` is per-instance config (ot-5); install scripts lint that the redundancy pair has distinct names. |
| Cross-machine MXAccess writes traverse plaintext gRPC | Phase 0 enforces `UseTls=true` for any non-loopback `Endpoint`; CI lints the sample configs. |
| gw API key leaked in logs | gw and `MxGatewayClient` already redact `authorization` metadata; phase 6 audit. |
| Memory leak in `EventPump` under high event rate | Bounded channel between `StreamEventsAsync` and per-sub fan-out, drop-newest with a metric counter; soak test catches. |
---
## Cross-cutting deliverables
- **Docs:** `docs/v2/Galaxy.Driver.md` (new), updates to
`docs/v2/dev-environment.md`, `docs/ServiceHosting.md`,
`docs/Redundancy.md`, `CLAUDE.md`.
- **Install scripts:** `scripts/install/Install-Services.ps1` removes
`OtOpcUaGalaxyHost`, adds `OtOpcUaWonderwareHistorian`, no Galaxy
service registration on the OtOpcUa node.
- **e2e:** `scripts/e2e/e2e-config.sample.json` — drop `OTOPCUA_GALAXY_*`
pipe vars, add `Drivers:Galaxy:Gateway:Endpoint` etc.
- **Memory:** retire stale Galaxy.Host entries; add gw dependency entry,
redundancy + client-name guidance.
---
## Order-of-work summary
```
Phase 0 (gw repo): gw-1, gw-2, gw-4, gw-9
Phase 1 (this): ot-1 — historian extension point
Phase 2 (this): ot-2 — alarm subsystem
Phase 3 (this): Driver.Historian.Wonderware sidecar
Phase 4 (this): Driver.Galaxy (new) behind backend flag
— depends on Phase 0, 1, 2
Phase 5 (this+gw): parity matrix
— drives gw-3 / gw-5 / gw-6 / gw-7 if gaps surface
Phase 6 (this): perf + hardening
Phase 7 (this): retire Galaxy.Host / Proxy / Shared
```
Phases 13 are independent of each other and can run in parallel. Phase 4
needs all three plus Phase 0. Phase 5 requires Phase 4. Phases 6 and 7 are
sequential after Phase 5.
+1050
View File
File diff suppressed because it is too large Load Diff
@@ -51,6 +51,7 @@ else
<li class="nav-item"><button class="nav-link @Tab("uns")" @onclick='() => _tab = "uns"'>UNS Structure</button></li>
<li class="nav-item"><button class="nav-link @Tab("namespaces")" @onclick='() => _tab = "namespaces"'>Namespaces</button></li>
<li class="nav-item"><button class="nav-link @Tab("drivers")" @onclick='() => _tab = "drivers"'>Drivers</button></li>
<li class="nav-item"><button class="nav-link @Tab("tags")" @onclick='() => _tab = "tags"'>Tags</button></li>
<li class="nav-item"><button class="nav-link @Tab("acls")" @onclick='() => _tab = "acls"'>ACLs</button></li>
<li class="nav-item"><button class="nav-link @Tab("redundancy")" @onclick='() => _tab = "redundancy"'>Redundancy</button></li>
<li class="nav-item"><button class="nav-link @Tab("audit")" @onclick='() => _tab = "audit"'>Audit</button></li>
@@ -89,6 +90,10 @@ else
{
<DriversTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
}
else if (_tab == "tags" && _currentDraft is not null)
{
<TagsTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
}
else if (_tab == "acls" && _currentDraft is not null)
{
<AclsTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
@@ -0,0 +1,372 @@
@using System.Text.Json
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages.Modbus
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@using ZB.MOM.WW.OtOpcUa.Driver.Modbus
@inject TagService TagSvc
@inject DriverInstanceService DriverSvc
@inject EquipmentService EquipmentSvc
@*
#155 — interactive Tag CRUD scoped to a draft generation. Conditional editor: when the
selected DriverInstance is Modbus, the address input switches to ModbusAddressEditor (#145)
so users get the live-parse preview + grammar validation. Other driver types fall back to
a generic JSON textarea, matching the DriversTab pattern from #147.
*@
<div class="d-flex justify-content-between mb-3">
<h4>Tags (draft gen @GenerationId)</h4>
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add tag</button>
</div>
<div class="row g-3 mb-3">
<div class="col-md-4">
<label class="form-label small text-muted">Filter by driver</label>
<select class="form-select form-select-sm" @bind="_filterDriverId" @bind:after="ReloadAsync">
<option value="">— all drivers —</option>
@if (_drivers is not null)
{
@foreach (var d in _drivers)
{
<option value="@d.DriverInstanceId">@d.Name (@d.DriverType)</option>
}
}
</select>
</div>
</div>
@if (_tags is null) { <p>Loading…</p> }
else if (_tags.Count == 0 && !_showForm) { <p class="text-muted">No tags in this filter.</p> }
else if (_tags.Count > 0)
{
<table class="table table-sm">
<thead>
<tr><th>Name</th><th>Driver</th><th>Equipment</th><th>DataType</th><th>Access</th><th>TagConfig</th><th></th></tr>
</thead>
<tbody>
@foreach (var t in _tags)
{
<tr>
<td>@t.Name</td>
<td><code>@t.DriverInstanceId</code></td>
<td>@(t.EquipmentId ?? "—")</td>
<td>@t.DataType</td>
<td>@t.AccessLevel</td>
<td class="font-monospace small text-truncate" style="max-width:18rem">@t.TagConfig</td>
<td>
<button class="btn btn-sm btn-outline-secondary me-1" @onclick="() => StartEdit(t)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(t.TagRowId)">Remove</button>
</td>
</tr>
}
</tbody>
</table>
}
@if (_showForm)
{
<div class="card mt-3">
<div class="card-body">
<h5>@(_editMode ? "Edit tag" : "New tag")</h5>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Name</label>
<input class="form-control" @bind="_draft.Name"/>
</div>
<div class="col-md-4">
<label class="form-label">DriverInstance</label>
<select class="form-select" @bind="_draft.DriverInstanceId" @bind:after="OnDriverChanged">
<option value="">— select driver —</option>
@if (_drivers is not null)
{
@foreach (var d in _drivers) { <option value="@d.DriverInstanceId">@d.Name (@d.DriverType)</option> }
}
</select>
</div>
<div class="col-md-4">
<label class="form-label">Equipment (optional)</label>
<select class="form-select" @bind="_draft.EquipmentId">
<option value="">— none (folder-path mode) —</option>
@if (_equipment is not null)
{
@foreach (var e in _equipment) { <option value="@e.EquipmentId">@e.Name</option> }
}
</select>
</div>
<div class="col-md-4">
<label class="form-label">DataType</label>
<input class="form-control" @bind="_draft.DataType" placeholder="Boolean / Int32 / Float / etc."/>
</div>
<div class="col-md-4">
<label class="form-label">AccessLevel</label>
<select class="form-select" @bind="_draft.AccessLevel">
@foreach (var a in Enum.GetValues<TagAccessLevel>())
{
<option value="@a">@a</option>
}
</select>
</div>
<div class="col-md-4">
<div class="form-check mt-4">
<input type="checkbox" class="form-check-input" @bind="_draft.WriteIdempotent"/>
<label class="form-check-label">WriteIdempotent</label>
</div>
</div>
</div>
<div class="mt-3">
@if (_isModbus)
{
<ModbusAddressEditor @bind-AddressString="_modbusAddress"
@bind-AddressString:after="OnAddressChanged"/>
}
else
{
<label class="form-label">TagConfig (driver-specific JSON or string)</label>
<textarea class="form-control font-monospace" rows="3" @bind="_draft.TagConfig"
placeholder='@("{\"address\": ...}")'></textarea>
}
</div>
@* #156 — advanced Modbus fields. Collapsed by default; the basic form covers the
typical edit workflow. Expander surfaces Deadband (#141) / UnitId override (#142) /
CoalesceProhibited (#143) for the multi-slave / noisy-analog / protected-hole
deployments. Save-side flushes these into TagConfig as a structured JSON object
that ModbusTagDto's BuildTag honours alongside the address string. *@
@if (_isModbus)
{
<div class="mt-3">
<button type="button" class="btn btn-sm btn-link p-0"
@onclick="() => _showAdvanced = !_showAdvanced">
@(_showAdvanced ? "▼ Advanced" : "▶ Advanced") (Deadband / UnitId override / CoalesceProhibited)
</button>
</div>
@if (_showAdvanced)
{
<div class="row g-3 mt-1 ps-3 border-start">
<div class="col-md-4">
<label class="form-label small">Deadband
<span class="text-muted">(numeric scalar types only)</span>
</label>
<input type="number" step="any" class="form-control form-control-sm"
@bind="_advancedDeadband" @bind:after="OnAdvancedChanged"
placeholder="e.g. 0.5"/>
<div class="form-text">Suppress publishes when |new - last| &lt; threshold.</div>
</div>
<div class="col-md-4">
<label class="form-label small">UnitId override
<span class="text-muted">(0255, blank = use driver default)</span>
</label>
<input type="number" min="0" max="255" class="form-control form-control-sm"
@bind="_advancedUnitId" @bind:after="OnAdvancedChanged"
placeholder="leave blank for driver-level"/>
<div class="form-text">Per-tag MBAP unit ID. Required when fronting a multi-slave gateway.</div>
</div>
<div class="col-md-4">
<label class="form-label small">CoalesceProhibited</label>
<div class="form-check mt-1">
<input type="checkbox" class="form-check-input"
@bind="_advancedCoalesceProhibited" @bind:after="OnAdvancedChanged"/>
<label class="form-check-label">Read in isolation (#143)</label>
</div>
<div class="form-text">Use when surrounding registers are write-only or fault on read.</div>
</div>
</div>
}
}
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
<div class="mt-3">
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
<button class="btn btn-sm btn-secondary ms-2" @onclick="Cancel">Cancel</button>
</div>
</div>
</div>
}
@code {
[Parameter] public long GenerationId { get; set; }
[Parameter] public string ClusterId { get; set; } = string.Empty;
private List<Tag>? _tags;
private List<DriverInstance>? _drivers;
private List<Equipment>? _equipment;
private string _filterDriverId = string.Empty;
private bool _showForm;
private bool _editMode;
private Tag _draft = NewBlankDraft();
private string? _error;
private bool _isModbus;
private string? _modbusAddress;
// #156 — advanced Modbus fields. Bound separately from _draft.TagConfig because they
// round-trip through a structured JSON shape, not a single string. Synced into TagConfig
// by OnAdvancedChanged / OnAddressChanged (whichever fires).
private bool _showAdvanced;
private double? _advancedDeadband;
private byte? _advancedUnitId;
private bool _advancedCoalesceProhibited;
private static Tag NewBlankDraft() => new()
{
TagId = string.Empty, DriverInstanceId = string.Empty, Name = string.Empty,
DataType = "Int32", AccessLevel = TagAccessLevel.Read, TagConfig = string.Empty,
};
protected override async Task OnParametersSetAsync()
{
_drivers = await DriverSvc.ListAsync(GenerationId, CancellationToken.None);
_equipment = await EquipmentSvc.ListAsync(GenerationId, CancellationToken.None);
await ReloadAsync();
}
private async Task ReloadAsync()
{
_tags = await TagSvc.ListAsync(GenerationId,
string.IsNullOrWhiteSpace(_filterDriverId) ? null : _filterDriverId,
equipmentId: null,
CancellationToken.None);
}
private void StartAdd()
{
_draft = NewBlankDraft();
_editMode = false;
_modbusAddress = null;
_isModbus = false;
_error = null;
_showForm = true;
ResetAdvanced();
}
private void ResetAdvanced()
{
_showAdvanced = false;
_advancedDeadband = null;
_advancedUnitId = null;
_advancedCoalesceProhibited = false;
}
private void StartEdit(Tag row)
{
_draft = new Tag
{
TagRowId = row.TagRowId,
GenerationId = row.GenerationId,
TagId = row.TagId,
DriverInstanceId = row.DriverInstanceId,
DeviceId = row.DeviceId,
EquipmentId = row.EquipmentId,
Name = row.Name,
FolderPath = row.FolderPath,
DataType = row.DataType,
AccessLevel = row.AccessLevel,
WriteIdempotent = row.WriteIdempotent,
PollGroupId = row.PollGroupId,
TagConfig = row.TagConfig,
};
_editMode = true;
OnDriverChanged();
// Try to extract addressString + advanced fields from existing JSON config so the
// form pre-fills correctly when an operator hits Edit on an existing row.
ResetAdvanced();
if (_isModbus) HydrateModbusFromTagConfig(row.TagConfig);
_error = null;
_showForm = true;
}
private void HydrateModbusFromTagConfig(string tagConfig)
{
try
{
using var doc = JsonDocument.Parse(tagConfig);
var root = doc.RootElement;
if (root.TryGetProperty("addressString", out var addr) && addr.ValueKind == JsonValueKind.String)
_modbusAddress = addr.GetString();
if (root.TryGetProperty("deadband", out var db) && db.ValueKind is JsonValueKind.Number)
_advancedDeadband = db.GetDouble();
if (root.TryGetProperty("unitId", out var uid) && uid.ValueKind is JsonValueKind.Number)
_advancedUnitId = uid.GetByte();
if (root.TryGetProperty("coalesceProhibited", out var cp) && cp.ValueKind is JsonValueKind.True or JsonValueKind.False)
_advancedCoalesceProhibited = cp.GetBoolean();
// Auto-expand the advanced panel when any of those fields was actually set so
// operators see immediately what's been configured.
if (_advancedDeadband.HasValue || _advancedUnitId.HasValue || _advancedCoalesceProhibited)
_showAdvanced = true;
}
catch { /* Malformed JSON falls back to empty advanced state. */ }
}
private void OnDriverChanged()
{
var driver = _drivers?.FirstOrDefault(d => d.DriverInstanceId == _draft.DriverInstanceId);
_isModbus = driver is not null
&& string.Equals(driver.DriverType, "Modbus", StringComparison.OrdinalIgnoreCase);
}
private void OnAddressChanged() => RefreshTagConfigJson();
private void OnAdvancedChanged() => RefreshTagConfigJson();
/// <summary>
/// Re-serializes the current address + advanced fields into TagConfig as a structured
/// JSON object. ModbusTagDto's BuildTag honours every field — addressString drives
/// the parser, while the structured bits (deadband / unitId / coalesceProhibited)
/// pass through directly. Fields with default / empty values are omitted from the
/// JSON to keep diffs in the existing draft-diff viewer clean.
/// </summary>
private void RefreshTagConfigJson()
{
if (string.IsNullOrWhiteSpace(_modbusAddress)
&& !_advancedDeadband.HasValue
&& !_advancedUnitId.HasValue
&& !_advancedCoalesceProhibited)
{
return;
}
var payload = new Dictionary<string, object?>();
if (!string.IsNullOrWhiteSpace(_modbusAddress)) payload["addressString"] = _modbusAddress;
if (_advancedDeadband.HasValue) payload["deadband"] = _advancedDeadband.Value;
if (_advancedUnitId.HasValue) payload["unitId"] = _advancedUnitId.Value;
if (_advancedCoalesceProhibited) payload["coalesceProhibited"] = true;
_draft.TagConfig = JsonSerializer.Serialize(payload);
}
private void Cancel()
{
_showForm = false;
_editMode = false;
}
private async Task SaveAsync()
{
_error = null;
try
{
if (string.IsNullOrWhiteSpace(_draft.Name) || string.IsNullOrWhiteSpace(_draft.DriverInstanceId))
{
_error = "Name and DriverInstance are required.";
return;
}
if (_editMode)
await TagSvc.UpdateAsync(_draft, CancellationToken.None);
else
await TagSvc.CreateAsync(GenerationId, _draft, CancellationToken.None);
_showForm = false;
_editMode = false;
await ReloadAsync();
}
catch (Exception ex) { _error = ex.Message; }
}
private async Task DeleteAsync(Guid id)
{
await TagSvc.DeleteAsync(id, CancellationToken.None);
await ReloadAsync();
}
}
@@ -0,0 +1,120 @@
@page "/modbus/diagnostics/{DriverInstanceId}"
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@inject DriverDiagnosticsClient Diagnostics
@*
#154 — operator-facing view of the Server's auto-prohibition state for a Modbus driver.
Fetches via DriverDiagnosticsClient (HttpClient against the Server's HealthEndpointsHost).
Refreshes on demand; auto-refresh is a future task once a SignalR diag channel exists.
*@
<PageTitle>Modbus diagnostics — @DriverInstanceId</PageTitle>
<div class="container py-4">
<h1>Modbus auto-prohibitions</h1>
<p class="text-muted">
Driver instance <code>@DriverInstanceId</code>. Live snapshot of coalesced ranges
the planner has learned to read individually (#148 / #150 / #151 / #152).
</p>
<div class="mb-3">
<button class="btn btn-sm btn-outline-primary" @onclick="LoadAsync" disabled="@_loading">
@(_loading ? "Loading…" : "Refresh")
</button>
@if (_lastRefreshed is not null)
{
<span class="text-muted ms-3 small">Last refreshed @_lastRefreshed.Value.ToLocalTime().ToString("HH:mm:ss")</span>
}
</div>
@if (_error is not null)
{
<div class="alert alert-danger">@_error</div>
}
else if (_response is null)
{
<p class="text-muted">Click <strong>Refresh</strong> to load.</p>
}
else if (_response.Count == 0)
{
<div class="alert alert-success">No auto-prohibitions. The planner is coalescing freely.</div>
}
else
{
<table class="table table-sm">
<thead>
<tr>
<th>Unit</th>
<th>Region</th>
<th>Start</th>
<th>End</th>
<th>Span</th>
<th>Status</th>
<th>Last probed</th>
</tr>
</thead>
<tbody>
@foreach (var r in _response.Ranges.OrderBy(r => r.UnitId).ThenBy(r => r.Region).ThenBy(r => r.StartAddress))
{
<tr>
<td><code>@r.UnitId</code></td>
<td><code>@r.Region</code></td>
<td><code>@r.StartAddress</code></td>
<td><code>@r.EndAddress</code></td>
<td>@(r.EndAddress - r.StartAddress + 1)</td>
<td>
@if (r.BisectionPending)
{
<span class="badge bg-warning text-dark">BISECTING</span>
}
else
{
<span class="badge bg-danger">ISOLATED</span>
}
</td>
<td class="small text-muted">@FormatTimeSince(r.LastProbedUtc)</td>
</tr>
}
</tbody>
</table>
}
</div>
@code {
[Parameter] public string DriverInstanceId { get; set; } = string.Empty;
private ModbusAutoProhibitionsResponse? _response;
private string? _error;
private bool _loading;
private DateTime? _lastRefreshed;
private async Task LoadAsync()
{
_loading = true;
_error = null;
try
{
_response = await Diagnostics.GetModbusAutoProhibitedRangesAsync(DriverInstanceId);
_lastRefreshed = DateTime.UtcNow;
if (_response is null)
_error = $"Server reports driver '{DriverInstanceId}' is not present or is not a Modbus driver.";
}
catch (Exception ex)
{
_error = $"Fetch failed: {ex.Message}";
}
finally
{
_loading = false;
}
}
private static string FormatTimeSince(DateTime utc)
{
var span = DateTime.UtcNow - utc;
if (span.TotalSeconds < 60) return $"{(int)span.TotalSeconds}s ago";
if (span.TotalMinutes < 60) return $"{(int)span.TotalMinutes}m ago";
if (span.TotalHours < 24) return $"{(int)span.TotalHours}h ago";
return $"{(int)span.TotalDays}d ago";
}
}
+10
View File
@@ -41,10 +41,20 @@ builder.Services.AddDbContext<OtOpcUaConfigDbContext>(opt =>
builder.Services.AddScoped<ClusterService>();
builder.Services.AddScoped<GenerationService>();
builder.Services.AddScoped<EquipmentService>();
builder.Services.AddScoped<TagService>();
builder.Services.AddScoped<UnsService>();
builder.Services.AddScoped<NamespaceService>();
builder.Services.AddScoped<DriverInstanceService>();
builder.Services.AddScoped<FocasDriverDetailService>();
// #154 — Server diagnostics client. Default base URL points at the same machine's
// HealthEndpointsHost (loopback :4841); deployments with remote Servers set
// "DriverDiagnostics:ServerBaseUrl" in appsettings.json.
builder.Services.AddHttpClient<DriverDiagnosticsClient>(client =>
{
var baseUrl = builder.Configuration["DriverDiagnostics:ServerBaseUrl"] ?? "http://localhost:4841/";
client.BaseAddress = new Uri(baseUrl);
});
builder.Services.AddScoped<NodeAclService>();
builder.Services.AddScoped<PermissionProbeService>();
builder.Services.AddScoped<AclChangeNotifier>();
@@ -0,0 +1,61 @@
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>
/// #154 — Admin-side client for the Server's driver-diagnostics HTTP endpoints. Wraps
/// <see cref="HttpClient"/> so Blazor pages can fetch per-driver runtime state from a
/// remote Server process. The base URL is configured at registration time
/// (typically read from <c>appsettings.json</c> at startup).
/// </summary>
/// <remarks>
/// One client instance per Server endpoint. Multi-server deployments register multiple
/// keyed clients. Errors propagate as exceptions; pages catch and surface to the
/// operator rather than swallowing.
/// </remarks>
public sealed class DriverDiagnosticsClient
{
private readonly HttpClient _http;
public DriverDiagnosticsClient(HttpClient http) => _http = http;
/// <summary>
/// Fetch the current Modbus auto-prohibition list for the named driver instance.
/// Returns null when the Server reports the driver doesn't exist or isn't a Modbus
/// driver. Throws on transport / serialization failures.
/// </summary>
public async Task<ModbusAutoProhibitionsResponse?> GetModbusAutoProhibitedRangesAsync(
string driverInstanceId, CancellationToken ct = default)
{
var resp = await _http.GetAsync(
$"/diagnostics/drivers/{Uri.EscapeDataString(driverInstanceId)}/modbus/auto-prohibited", ct)
.ConfigureAwait(false);
if (resp.StatusCode is System.Net.HttpStatusCode.NotFound or System.Net.HttpStatusCode.BadRequest)
return null;
resp.EnsureSuccessStatusCode();
return await resp.Content.ReadFromJsonAsync<ModbusAutoProhibitionsResponse>(cancellationToken: ct).ConfigureAwait(false);
}
}
/// <summary>
/// Server response shape for the Modbus auto-prohibition diagnostic. Mirrors the JSON the
/// <c>HealthEndpointsHost</c> serialises; fields are flat strings/numbers so the
/// Admin-side client doesn't take a dependency on the Driver.Modbus assembly's
/// <c>ModbusAutoProhibition</c> record.
/// </summary>
public sealed class ModbusAutoProhibitionsResponse
{
public string DriverInstanceId { get; set; } = string.Empty;
public int Count { get; set; }
public List<ModbusAutoProhibitionRow> Ranges { get; set; } = new();
}
public sealed class ModbusAutoProhibitionRow
{
public byte UnitId { get; set; }
public string Region { get; set; } = string.Empty;
public ushort StartAddress { get; set; }
public ushort EndAddress { get; set; }
public DateTime LastProbedUtc { get; set; }
public bool BisectionPending { get; set; }
}
@@ -0,0 +1,71 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>
/// #155 — Tag CRUD scoped to a draft generation. Tags are the canonical signal definitions
/// (one row per OPC UA variable) the Server materialises into the address space at startup.
/// Mirrors the shape of <see cref="EquipmentService"/>; writes are restricted to draft
/// generations only (published generations are immutable per the validation pipeline).
/// </summary>
public sealed class TagService(OtOpcUaConfigDbContext db)
{
/// <summary>Lists all tags in a generation, ordered by name. Optional driver / equipment filter.</summary>
public Task<List<Tag>> ListAsync(long generationId,
string? driverInstanceId = null,
string? equipmentId = null,
CancellationToken ct = default)
{
var query = db.Tags.AsNoTracking().Where(t => t.GenerationId == generationId);
if (!string.IsNullOrWhiteSpace(driverInstanceId))
query = query.Where(t => t.DriverInstanceId == driverInstanceId);
if (!string.IsNullOrWhiteSpace(equipmentId))
query = query.Where(t => t.EquipmentId == equipmentId);
return query.OrderBy(t => t.Name).ToListAsync(ct);
}
/// <summary>
/// Creates a new tag row in the given draft. TagId is auto-derived as a GUID — the
/// human-friendly Name is the user-facing identifier.
/// </summary>
public async Task<Tag> CreateAsync(long draftId, Tag input, CancellationToken ct)
{
input.GenerationId = draftId;
if (string.IsNullOrWhiteSpace(input.TagId))
input.TagId = Guid.NewGuid().ToString("N");
db.Tags.Add(input);
await db.SaveChangesAsync(ct);
return input;
}
public async Task UpdateAsync(Tag updated, CancellationToken ct)
{
var existing = await db.Tags
.FirstOrDefaultAsync(t => t.TagRowId == updated.TagRowId, ct)
?? throw new InvalidOperationException($"Tag row {updated.TagRowId} not found");
// Editable fields. TagId / GenerationId are immutable; the Validation pipeline rejects
// changes that would break referential integrity (sp_ValidateDraft per decision #110).
existing.Name = updated.Name;
existing.DriverInstanceId = updated.DriverInstanceId;
existing.DeviceId = updated.DeviceId;
existing.EquipmentId = updated.EquipmentId;
existing.FolderPath = updated.FolderPath;
existing.DataType = updated.DataType;
existing.AccessLevel = updated.AccessLevel;
existing.WriteIdempotent = updated.WriteIdempotent;
existing.PollGroupId = updated.PollGroupId;
existing.TagConfig = updated.TagConfig;
await db.SaveChangesAsync(ct);
}
public async Task DeleteAsync(Guid tagRowId, CancellationToken ct)
{
var existing = await db.Tags.FirstOrDefaultAsync(t => t.TagRowId == tagRowId, ct);
if (existing is null) return;
db.Tags.Remove(existing);
await db.SaveChangesAsync(ct);
}
}
@@ -0,0 +1,19 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Point-in-time state of a single historian cluster node, included inside
/// <see cref="HistorianHealthSnapshot.Nodes"/> when the backend is clustered.
/// </summary>
/// <param name="Name">Node identifier — backend-specific (typically a hostname).</param>
/// <param name="IsHealthy">True when the node is currently considered usable for reads.</param>
/// <param name="CooldownUntil">When the next retry against an unhealthy node is allowed; null when no cooldown is active.</param>
/// <param name="FailureCount">Consecutive failures observed against this node since the last success.</param>
/// <param name="LastError">Diagnostic text from the last failure against this node; null when no failures.</param>
/// <param name="LastFailureTime">UTC of the last failure against this node; null when no failures.</param>
public sealed record HistorianClusterNodeState(
string Name,
bool IsHealthy,
DateTime? CooldownUntil,
int FailureCount,
string? LastError,
DateTime? LastFailureTime);
@@ -0,0 +1,32 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Point-in-time runtime health of a historian data source. Returned by
/// <see cref="IHistorianDataSource.GetHealthSnapshot"/> and projected onto the
/// server status dashboard.
/// </summary>
/// <param name="TotalQueries">Lifetime count of read calls received.</param>
/// <param name="TotalSuccesses">Subset of <paramref name="TotalQueries"/> that completed without error.</param>
/// <param name="TotalFailures">Subset of <paramref name="TotalQueries"/> that ended in error.</param>
/// <param name="ConsecutiveFailures">Failures since the last success — non-zero means the source is currently degraded.</param>
/// <param name="LastSuccessTime">UTC of the most recent successful read; null if none yet.</param>
/// <param name="LastFailureTime">UTC of the most recent failed read; null if none yet.</param>
/// <param name="LastError">Diagnostic text from the most recent failure; null when no failures recorded.</param>
/// <param name="ProcessConnectionOpen">True when the source's process-data connection is currently established.</param>
/// <param name="EventConnectionOpen">True when the source's event-data connection is currently established. Some backends share one connection — implementations may report the same value here as <paramref name="ProcessConnectionOpen"/>.</param>
/// <param name="ActiveProcessNode">Cluster node currently serving process reads; null when no node is active or the backend is non-clustered.</param>
/// <param name="ActiveEventNode">Cluster node currently serving event reads; null when no node is active or the backend is non-clustered.</param>
/// <param name="Nodes">Per-cluster-node state. Empty when the backend is non-clustered.</param>
public sealed record HistorianHealthSnapshot(
long TotalQueries,
long TotalSuccesses,
long TotalFailures,
int ConsecutiveFailures,
DateTime? LastSuccessTime,
DateTime? LastFailureTime,
string? LastError,
bool ProcessConnectionOpen,
bool EventConnectionOpen,
string? ActiveProcessNode,
string? ActiveEventNode,
IReadOnlyList<HistorianClusterNodeState> Nodes);
@@ -0,0 +1,74 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Server-side historian data source. Registered with the server's history router
/// and resolved per OPC UA namespace, independent of any driver's lifecycle.
/// </summary>
/// <remarks>
/// Distinct from <see cref="IHistoryProvider"/>:
/// <list type="bullet">
/// <item><see cref="IHistoryProvider"/> is a *driver capability* — the server
/// dispatches to it via the driver instance.</item>
/// <item><see cref="IHistorianDataSource"/> is a *server registration* — the
/// server resolves it via namespace and calls it directly, so a single
/// historian (e.g. Wonderware) can serve many drivers' nodes, and drivers can
/// restart without dropping history availability.</item>
/// </list>
/// All values returned use the shared <see cref="DataValueSnapshot"/> /
/// <see cref="HistoricalEvent"/> shapes; backend-specific quality / type encodings
/// are translated to OPC UA <c>StatusCode</c> uints inside the data source.
/// </remarks>
public interface IHistorianDataSource : IDisposable
{
/// <summary>
/// Read raw historical samples for a single tag over a time range.
/// </summary>
Task<HistoryReadResult> ReadRawAsync(
string fullReference,
DateTime startUtc,
DateTime endUtc,
uint maxValuesPerNode,
CancellationToken cancellationToken);
/// <summary>
/// Read processed (interval-bucketed) samples — average / min / max / count / etc.
/// A bucket with no source data returns a sample whose
/// <see cref="DataValueSnapshot.StatusCode"/> indicates BadNoData.
/// </summary>
Task<HistoryReadResult> ReadProcessedAsync(
string fullReference,
DateTime startUtc,
DateTime endUtc,
TimeSpan interval,
HistoryAggregateType aggregate,
CancellationToken cancellationToken);
/// <summary>
/// Read one sample per requested timestamp — OPC UA HistoryReadAtTime service.
/// Implementations interpolate or return prior-boundary samples per their
/// backend's policy. The returned list MUST be the same length and order as
/// <paramref name="timestampsUtc"/>; gaps are returned as Bad-quality snapshots.
/// </summary>
Task<HistoryReadResult> ReadAtTimeAsync(
string fullReference,
IReadOnlyList<DateTime> timestampsUtc,
CancellationToken cancellationToken);
/// <summary>
/// Read historical alarm / event records — OPC UA HistoryReadEvents service.
/// Distinct from any live event stream; sources here come from the historian's
/// event log. <paramref name="sourceName"/> is null to return all sources.
/// </summary>
Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName,
DateTime startUtc,
DateTime endUtc,
int maxEvents,
CancellationToken cancellationToken);
/// <summary>
/// Point-in-time health snapshot for diagnostics and dashboards. Pure
/// observation; never blocks on backend I/O.
/// </summary>
HistorianHealthSnapshot GetHealthSnapshot();
}
@@ -62,10 +62,41 @@ public interface IVariableHandle
/// <param name="SourceName">Human-readable alarm name used for the <c>SourceName</c> event field.</param>
/// <param name="InitialSeverity">Severity at address-space build time; updates arrive via <see cref="IAlarmConditionSink"/>.</param>
/// <param name="InitialDescription">Initial description; updates arrive via <see cref="IAlarmConditionSink"/>.</param>
/// <param name="InAlarmRef">
/// Driver-side full reference for the boolean attribute that toggles when the
/// alarm condition becomes active. Consumed by the server-level alarm-condition
/// service to subscribe to active/inactive transitions. Null when the driver
/// reports alarm transitions through some other channel.
/// </param>
/// <param name="PriorityRef">
/// Driver-side full reference for the integer attribute carrying the alarm's
/// current priority / severity. Live updates flow through the same subscription
/// pipeline as <paramref name="InAlarmRef"/>. Null when the driver does not
/// expose live priority changes.
/// </param>
/// <param name="DescAttrNameRef">
/// Driver-side full reference for the string attribute carrying the human-readable
/// description / message. Null when the driver does not expose a live description.
/// </param>
/// <param name="AckedRef">
/// Driver-side full reference for the boolean attribute that toggles when the
/// alarm is acknowledged. Null when acknowledgement is not observable on the
/// driver side.
/// </param>
/// <param name="AckMsgWriteRef">
/// Driver-side full reference the server writes to acknowledge the condition,
/// typically the alarm's <c>.AckMsg</c> attribute. Null when the driver does not
/// accept acknowledgement writes (or routes them through a separate API).
/// </param>
public sealed record AlarmConditionInfo(
string SourceName,
AlarmSeverity InitialSeverity,
string? InitialDescription);
string? InitialDescription,
string? InAlarmRef = null,
string? PriorityRef = null,
string? DescAttrNameRef = null,
string? AckedRef = null,
string? AckMsgWriteRef = null);
/// <summary>
/// Sink a concrete address-space builder returns from <see cref="IVariableHandle.MarkAsAlarmCondition"/>.
@@ -6,7 +6,7 @@ using System.Threading.Tasks;
using MessagePack;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Alarms;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
using ZB.MOM.WW.OtOpcUa.Driver.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;
@@ -4,7 +4,7 @@ using System.Threading;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Ipc;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
@@ -28,6 +28,10 @@
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
<!-- PR 3.2: Historian SDK code lifted to the Wonderware sidecar. Galaxy.Host still
consumes the historian types (MxAccessGalaxyBackend, Program) until phase 7,
so reference the sidecar project to keep building. -->
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.csproj"/>
</ItemGroup>
<ItemGroup>
@@ -39,34 +43,6 @@
<HintPath>..\..\lib\ArchestrA.MxAccess.dll</HintPath>
<Private>true</Private>
</Reference>
<!-- Wonderware Historian SDK — consumed by Backend/Historian/ for HistoryReadAsync.
Previously lived in the v1 Historian.Aveva plugin; folded into Driver.Galaxy.Host
for PR #5 because this host is already Galaxy-specific. -->
<Reference Include="aahClientManaged">
<HintPath>..\..\lib\aahClientManaged.dll</HintPath>
<EmbedInteropTypes>false</EmbedInteropTypes>
</Reference>
<Reference Include="aahClientCommon">
<HintPath>..\..\lib\aahClientCommon.dll</HintPath>
<EmbedInteropTypes>false</EmbedInteropTypes>
</Reference>
</ItemGroup>
<ItemGroup>
<!-- Historian SDK native and satellite DLLs — staged beside the host exe so the
aahClientManaged wrapper can P/Invoke into them without an AssemblyResolve hook. -->
<None Include="..\..\lib\aahClient.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="..\..\lib\Historian.CBE.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="..\..\lib\Historian.DPAPI.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="..\..\lib\ArchestrA.CloudHistorian.Contract.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
@@ -2,7 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
{
/// <summary>
/// Thread-safe, pure-logic endpoint picker for the Wonderware Historian cluster. Tracks which
@@ -1,6 +1,6 @@
using System;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
{
/// <summary>
/// Point-in-time state of a single historian cluster node. One entry per configured node
@@ -1,6 +1,6 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
{
/// <summary>
/// Wonderware Historian SDK configuration. Populated from environment variables at Host
@@ -6,7 +6,7 @@ using System.Threading.Tasks;
using ArchestrA;
using Serilog;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
{
/// <summary>
/// Reads historical data from the Wonderware Historian via the aahClientManaged SDK.
@@ -1,6 +1,6 @@
using System;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
{
/// <summary>
/// SDK-free representation of a Historian event record. Prevents ArchestrA types from
@@ -1,7 +1,7 @@
using System;
using System.Collections.Generic;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
{
/// <summary>
/// Point-in-time runtime health of the historian subsystem — consumed by the status dashboard
@@ -1,4 +1,4 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
/// <summary>
/// Maps a raw OPC DA quality byte (as returned by Wonderware Historian's <c>OpcQuality</c>)
@@ -1,6 +1,6 @@
using System;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
{
/// <summary>
/// OPC-UA-free representation of a single historical data point. The Host returns these
@@ -2,7 +2,7 @@ using System;
using System.Threading;
using ArchestrA;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
{
/// <summary>
/// Creates and opens Historian SDK connections. Extracted so tests can inject fakes that
@@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend
{
/// <summary>
/// OPC-UA-free surface for the Wonderware Historian subsystem inside Galaxy.Host.
@@ -0,0 +1,168 @@
using System;
using MessagePack;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
// ============================================================================
// Wire DTOs for the sidecar pipe protocol. The sidecar speaks its own legacy
// shape (List<HistorianSample> etc.) — the .NET 10 client (PR 3.4) translates
// to / from Core.Abstractions.DataValueSnapshot + HistoricalEvent.
//
// Timestamps cross the wire as DateTime ticks (long) to dodge MessagePack's
// DateTime kind/timezone quirks; both sides convert with DateTime(ticks, Utc).
// ============================================================================
/// <summary>Single historical data point. Quality is the raw OPC DA byte; client maps to OPC UA StatusCode.</summary>
[MessagePackObject]
public sealed class HistorianSampleDto
{
/// <summary>MessagePack-serialized value bytes. Client deserializes per the tag's mx_data_type.</summary>
[Key(0)] public byte[]? ValueBytes { get; set; }
/// <summary>Raw OPC DA quality byte from the historian SDK (low 8 bits of OpcQuality).</summary>
[Key(1)] public byte Quality { get; set; }
[Key(2)] public long TimestampUtcTicks { get; set; }
}
/// <summary>Aggregate bucket; <c>Value</c> is null when the aggregate is unavailable for the bucket.</summary>
[MessagePackObject]
public sealed class HistorianAggregateSampleDto
{
[Key(0)] public double? Value { get; set; }
[Key(1)] public long TimestampUtcTicks { get; set; }
}
/// <summary>Historian event row.</summary>
[MessagePackObject]
public sealed class HistorianEventDto
{
[Key(0)] public string EventId { get; set; } = string.Empty;
[Key(1)] public string? Source { get; set; }
[Key(2)] public long EventTimeUtcTicks { get; set; }
[Key(3)] public long ReceivedTimeUtcTicks { get; set; }
[Key(4)] public string? DisplayText { get; set; }
[Key(5)] public ushort Severity { get; set; }
}
/// <summary>Alarm event to persist back into the historian event store.</summary>
[MessagePackObject]
public sealed class AlarmHistorianEventDto
{
[Key(0)] public string EventId { get; set; } = string.Empty;
[Key(1)] public string SourceName { get; set; } = string.Empty;
[Key(2)] public string? ConditionId { get; set; }
[Key(3)] public string AlarmType { get; set; } = string.Empty;
[Key(4)] public string? Message { get; set; }
[Key(5)] public ushort Severity { get; set; }
[Key(6)] public long EventTimeUtcTicks { get; set; }
[Key(7)] public string? AckComment { get; set; }
}
// ===== Read Raw =====
[MessagePackObject]
public sealed class ReadRawRequest
{
[Key(0)] public string TagName { get; set; } = string.Empty;
[Key(1)] public long StartUtcTicks { get; set; }
[Key(2)] public long EndUtcTicks { get; set; }
[Key(3)] public int MaxValues { get; set; }
[Key(4)] public string CorrelationId { get; set; } = string.Empty;
}
[MessagePackObject]
public sealed class ReadRawReply
{
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
[Key(1)] public bool Success { get; set; }
[Key(2)] public string? Error { get; set; }
[Key(3)] public HistorianSampleDto[] Samples { get; set; } = Array.Empty<HistorianSampleDto>();
}
// ===== Read Processed =====
[MessagePackObject]
public sealed class ReadProcessedRequest
{
[Key(0)] public string TagName { get; set; } = string.Empty;
[Key(1)] public long StartUtcTicks { get; set; }
[Key(2)] public long EndUtcTicks { get; set; }
[Key(3)] public double IntervalMs { get; set; }
/// <summary>
/// Wonderware AnalogSummary column name: "Average", "Minimum", "Maximum", "ValueCount".
/// The .NET 10 client maps OPC UA aggregate enum → column.
/// </summary>
[Key(4)] public string AggregateColumn { get; set; } = string.Empty;
[Key(5)] public string CorrelationId { get; set; } = string.Empty;
}
[MessagePackObject]
public sealed class ReadProcessedReply
{
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
[Key(1)] public bool Success { get; set; }
[Key(2)] public string? Error { get; set; }
[Key(3)] public HistorianAggregateSampleDto[] Buckets { get; set; } = Array.Empty<HistorianAggregateSampleDto>();
}
// ===== Read At-Time =====
[MessagePackObject]
public sealed class ReadAtTimeRequest
{
[Key(0)] public string TagName { get; set; } = string.Empty;
[Key(1)] public long[] TimestampsUtcTicks { get; set; } = Array.Empty<long>();
[Key(2)] public string CorrelationId { get; set; } = string.Empty;
}
[MessagePackObject]
public sealed class ReadAtTimeReply
{
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
[Key(1)] public bool Success { get; set; }
[Key(2)] public string? Error { get; set; }
[Key(3)] public HistorianSampleDto[] Samples { get; set; } = Array.Empty<HistorianSampleDto>();
}
// ===== Read Events =====
[MessagePackObject]
public sealed class ReadEventsRequest
{
[Key(0)] public string? SourceName { get; set; }
[Key(1)] public long StartUtcTicks { get; set; }
[Key(2)] public long EndUtcTicks { get; set; }
[Key(3)] public int MaxEvents { get; set; }
[Key(4)] public string CorrelationId { get; set; } = string.Empty;
}
[MessagePackObject]
public sealed class ReadEventsReply
{
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
[Key(1)] public bool Success { get; set; }
[Key(2)] public string? Error { get; set; }
[Key(3)] public HistorianEventDto[] Events { get; set; } = Array.Empty<HistorianEventDto>();
}
// ===== Write Alarm Events =====
[MessagePackObject]
public sealed class WriteAlarmEventsRequest
{
[Key(0)] public AlarmHistorianEventDto[] Events { get; set; } = Array.Empty<AlarmHistorianEventDto>();
[Key(1)] public string CorrelationId { get; set; } = string.Empty;
}
[MessagePackObject]
public sealed class WriteAlarmEventsReply
{
[Key(0)] public string CorrelationId { get; set; } = string.Empty;
[Key(1)] public bool Success { get; set; }
[Key(2)] public string? Error { get; set; }
/// <summary>Per-event success flag, parallel to <see cref="WriteAlarmEventsRequest.Events"/>.</summary>
[Key(3)] public bool[] PerEventOk { get; set; } = Array.Empty<bool>();
}
@@ -0,0 +1,68 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using MessagePack;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
/// <summary>
/// Reads length-prefixed, kind-tagged frames from a stream. Single-consumer — do not call
/// <see cref="ReadFrameAsync"/> from multiple threads against the same instance. Mirror of
/// Driver.Galaxy.Shared.FrameReader; sidecar carries its own copy so the deletion of
/// Galaxy.Shared in PR 7.2 doesn't reach the sidecar.
/// </summary>
public sealed class FrameReader : IDisposable
{
private readonly Stream _stream;
private readonly bool _leaveOpen;
public FrameReader(Stream stream, bool leaveOpen = false)
{
_stream = stream ?? throw new ArgumentNullException(nameof(stream));
_leaveOpen = leaveOpen;
}
public async Task<(MessageKind Kind, byte[] Body)?> ReadFrameAsync(CancellationToken ct)
{
var lengthPrefix = new byte[Framing.LengthPrefixSize];
if (!await ReadExactAsync(lengthPrefix, ct).ConfigureAwait(false))
return null; // clean EOF on frame boundary
var length = (lengthPrefix[0] << 24) | (lengthPrefix[1] << 16) | (lengthPrefix[2] << 8) | lengthPrefix[3];
if (length < 0 || length > Framing.MaxFrameBodyBytes)
throw new InvalidDataException($"Sidecar IPC frame length {length} out of range.");
var kindByte = _stream.ReadByte();
if (kindByte < 0) throw new EndOfStreamException("EOF after length prefix, before kind byte.");
var body = new byte[length];
if (!await ReadExactAsync(body, ct).ConfigureAwait(false))
throw new EndOfStreamException("EOF mid-frame.");
return ((MessageKind)(byte)kindByte, body);
}
public static T Deserialize<T>(byte[] body) => MessagePackSerializer.Deserialize<T>(body);
private async Task<bool> ReadExactAsync(byte[] buffer, CancellationToken ct)
{
var offset = 0;
while (offset < buffer.Length)
{
var read = await _stream.ReadAsync(buffer, offset, buffer.Length - offset, ct).ConfigureAwait(false);
if (read == 0)
{
if (offset == 0) return false;
throw new EndOfStreamException($"Stream ended after reading {offset} of {buffer.Length} bytes.");
}
offset += read;
}
return true;
}
public void Dispose()
{
if (!_leaveOpen) _stream.Dispose();
}
}
@@ -0,0 +1,57 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using MessagePack;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
/// <summary>
/// Writes length-prefixed, kind-tagged MessagePack frames to a stream. Thread-safe via
/// <see cref="SemaphoreSlim"/> so concurrent producers (heartbeat + reply paths) get
/// serialized writes. Mirror of Driver.Galaxy.Shared.FrameWriter; sidecar carries its
/// own copy.
/// </summary>
public sealed class FrameWriter : IDisposable
{
private readonly Stream _stream;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly bool _leaveOpen;
public FrameWriter(Stream stream, bool leaveOpen = false)
{
_stream = stream ?? throw new ArgumentNullException(nameof(stream));
_leaveOpen = leaveOpen;
}
public async Task WriteAsync<T>(MessageKind kind, T message, CancellationToken ct)
{
var body = MessagePackSerializer.Serialize(message, cancellationToken: ct);
if (body.Length > Framing.MaxFrameBodyBytes)
throw new InvalidOperationException(
$"Sidecar IPC frame body {body.Length} exceeds {Framing.MaxFrameBodyBytes} byte cap.");
var lengthPrefix = new byte[Framing.LengthPrefixSize];
// Big-endian — easy to read in hex dumps.
lengthPrefix[0] = (byte)((body.Length >> 24) & 0xFF);
lengthPrefix[1] = (byte)((body.Length >> 16) & 0xFF);
lengthPrefix[2] = (byte)((body.Length >> 8) & 0xFF);
lengthPrefix[3] = (byte)( body.Length & 0xFF);
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
await _stream.WriteAsync(lengthPrefix, 0, lengthPrefix.Length, ct).ConfigureAwait(false);
_stream.WriteByte((byte)kind);
await _stream.WriteAsync(body, 0, body.Length, ct).ConfigureAwait(false);
await _stream.FlushAsync(ct).ConfigureAwait(false);
}
finally { _gate.Release(); }
}
public void Dispose()
{
_gate.Dispose();
if (!_leaveOpen) _stream.Dispose();
}
}
@@ -0,0 +1,48 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
/// <summary>
/// Length-prefixed framing constants for the Wonderware historian sidecar pipe protocol.
/// Each frame on the wire is:
/// <c>[4-byte big-endian length][1-byte message kind][MessagePack body]</c>.
/// Length is the body size only; the kind byte is not part of the prefixed length.
/// </summary>
/// <remarks>
/// Mirrors the Galaxy.Shared framing exactly so the same FrameReader/FrameWriter pattern
/// works on both sides. The sidecar's protocol is independent — both the .NET 4.8 server
/// side and the .NET 10 client (PR 3.4) carry their own copies of these constants and
/// stay in sync via the round-trip test matrix.
/// </remarks>
public static class Framing
{
public const int LengthPrefixSize = 4;
public const int KindByteSize = 1;
/// <summary>16 MiB cap protects the receiver from a hostile or buggy peer.</summary>
public const int MaxFrameBodyBytes = 16 * 1024 * 1024;
}
/// <summary>
/// Wire identifier for each historian sidecar message. Values are stable — never reorder;
/// append new contracts at the end. The .NET 10 client and the .NET 4.8 sidecar must
/// agree on every value here.
/// </summary>
public enum MessageKind : byte
{
Hello = 0x01,
HelloAck = 0x02,
ReadRawRequest = 0x10,
ReadRawReply = 0x11,
ReadProcessedRequest = 0x12,
ReadProcessedReply = 0x13,
ReadAtTimeRequest = 0x14,
ReadAtTimeReply = 0x15,
ReadEventsRequest = 0x16,
ReadEventsReply = 0x17,
WriteAlarmEventsRequest = 0x20,
WriteAlarmEventsReply = 0x21,
}
@@ -0,0 +1,32 @@
using MessagePack;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
/// <summary>
/// First frame of every connection. Advertises the sidecar protocol version and the
/// per-process shared secret the supervisor passed at spawn time.
/// </summary>
[MessagePackObject]
public sealed class Hello
{
public const int CurrentMajor = 1;
public const int CurrentMinor = 0;
[Key(0)] public int ProtocolMajor { get; set; } = CurrentMajor;
[Key(1)] public int ProtocolMinor { get; set; } = CurrentMinor;
[Key(2)] public string PeerName { get; set; } = string.Empty;
/// <summary>Per-process shared secret — verified against the value the supervisor passed at spawn time.</summary>
[Key(3)] public string SharedSecret { get; set; } = string.Empty;
}
[MessagePackObject]
public sealed class HelloAck
{
[Key(0)] public int ProtocolMajor { get; set; } = Hello.CurrentMajor;
[Key(1)] public int ProtocolMinor { get; set; } = Hello.CurrentMinor;
[Key(2)] public bool Accepted { get; set; }
[Key(3)] public string? RejectReason { get; set; }
[Key(4)] public string HostName { get; set; } = string.Empty;
}
@@ -0,0 +1,250 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MessagePack;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
/// <summary>
/// Sidecar-side dispatcher. Each post-Hello frame routes by <see cref="MessageKind"/> to
/// the right historian operation and the result frame is written back through the same
/// pipe. Per-call exceptions are caught and surfaced as <c>Success=false, Error=...</c>
/// replies so a single bad request doesn't kill the connection.
/// </summary>
public sealed class HistorianFrameHandler : IFrameHandler
{
private readonly IHistorianDataSource _historian;
private readonly IAlarmEventWriter? _alarmWriter;
private readonly ILogger _logger;
public HistorianFrameHandler(
IHistorianDataSource historian,
ILogger logger,
IAlarmEventWriter? alarmWriter = null)
{
_historian = historian ?? throw new ArgumentNullException(nameof(historian));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_alarmWriter = alarmWriter;
}
public Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct)
=> kind switch
{
MessageKind.ReadRawRequest => HandleReadRawAsync(body, writer, ct),
MessageKind.ReadProcessedRequest => HandleReadProcessedAsync(body, writer, ct),
MessageKind.ReadAtTimeRequest => HandleReadAtTimeAsync(body, writer, ct),
MessageKind.ReadEventsRequest => HandleReadEventsAsync(body, writer, ct),
MessageKind.WriteAlarmEventsRequest => HandleWriteAlarmEventsAsync(body, writer, ct),
_ => UnknownAsync(kind),
};
private Task UnknownAsync(MessageKind kind)
{
_logger.Warning("Sidecar received unsupported frame kind {Kind}; dropping", kind);
return Task.CompletedTask;
}
private async Task HandleReadRawAsync(byte[] body, FrameWriter writer, CancellationToken ct)
{
var req = MessagePackSerializer.Deserialize<ReadRawRequest>(body);
var reply = new ReadRawReply { CorrelationId = req.CorrelationId };
try
{
var samples = await _historian.ReadRawAsync(
req.TagName,
new DateTime(req.StartUtcTicks, DateTimeKind.Utc),
new DateTime(req.EndUtcTicks, DateTimeKind.Utc),
req.MaxValues,
ct).ConfigureAwait(false);
reply.Success = true;
reply.Samples = ToWire(samples);
}
catch (Exception ex)
{
_logger.Warning(ex, "Sidecar ReadRaw failed for {Tag}", req.TagName);
reply.Success = false;
reply.Error = ex.Message;
}
await writer.WriteAsync(MessageKind.ReadRawReply, reply, ct).ConfigureAwait(false);
}
private async Task HandleReadProcessedAsync(byte[] body, FrameWriter writer, CancellationToken ct)
{
var req = MessagePackSerializer.Deserialize<ReadProcessedRequest>(body);
var reply = new ReadProcessedReply { CorrelationId = req.CorrelationId };
try
{
var buckets = await _historian.ReadAggregateAsync(
req.TagName,
new DateTime(req.StartUtcTicks, DateTimeKind.Utc),
new DateTime(req.EndUtcTicks, DateTimeKind.Utc),
req.IntervalMs,
req.AggregateColumn,
ct).ConfigureAwait(false);
reply.Success = true;
reply.Buckets = ToWire(buckets);
}
catch (Exception ex)
{
_logger.Warning(ex, "Sidecar ReadProcessed failed for {Tag}", req.TagName);
reply.Success = false;
reply.Error = ex.Message;
}
await writer.WriteAsync(MessageKind.ReadProcessedReply, reply, ct).ConfigureAwait(false);
}
private async Task HandleReadAtTimeAsync(byte[] body, FrameWriter writer, CancellationToken ct)
{
var req = MessagePackSerializer.Deserialize<ReadAtTimeRequest>(body);
var reply = new ReadAtTimeReply { CorrelationId = req.CorrelationId };
try
{
var timestamps = new DateTime[req.TimestampsUtcTicks.Length];
for (var i = 0; i < timestamps.Length; i++)
timestamps[i] = new DateTime(req.TimestampsUtcTicks[i], DateTimeKind.Utc);
var samples = await _historian.ReadAtTimeAsync(req.TagName, timestamps, ct).ConfigureAwait(false);
reply.Success = true;
reply.Samples = ToWire(samples);
}
catch (Exception ex)
{
_logger.Warning(ex, "Sidecar ReadAtTime failed for {Tag}", req.TagName);
reply.Success = false;
reply.Error = ex.Message;
}
await writer.WriteAsync(MessageKind.ReadAtTimeReply, reply, ct).ConfigureAwait(false);
}
private async Task HandleReadEventsAsync(byte[] body, FrameWriter writer, CancellationToken ct)
{
var req = MessagePackSerializer.Deserialize<ReadEventsRequest>(body);
var reply = new ReadEventsReply { CorrelationId = req.CorrelationId };
try
{
var events = await _historian.ReadEventsAsync(
req.SourceName,
new DateTime(req.StartUtcTicks, DateTimeKind.Utc),
new DateTime(req.EndUtcTicks, DateTimeKind.Utc),
req.MaxEvents,
ct).ConfigureAwait(false);
reply.Success = true;
reply.Events = ToWire(events);
}
catch (Exception ex)
{
_logger.Warning(ex, "Sidecar ReadEvents failed for source {Source}", req.SourceName);
reply.Success = false;
reply.Error = ex.Message;
}
await writer.WriteAsync(MessageKind.ReadEventsReply, reply, ct).ConfigureAwait(false);
}
private async Task HandleWriteAlarmEventsAsync(byte[] body, FrameWriter writer, CancellationToken ct)
{
var req = MessagePackSerializer.Deserialize<WriteAlarmEventsRequest>(body);
var reply = new WriteAlarmEventsReply { CorrelationId = req.CorrelationId };
if (_alarmWriter is null)
{
reply.Success = false;
reply.Error = "Sidecar not configured with an alarm-event writer.";
reply.PerEventOk = new bool[req.Events.Length];
await writer.WriteAsync(MessageKind.WriteAlarmEventsReply, reply, ct).ConfigureAwait(false);
return;
}
try
{
var perEvent = await _alarmWriter.WriteAsync(req.Events, ct).ConfigureAwait(false);
reply.PerEventOk = perEvent;
reply.Success = true;
// Whole-batch Success stays true even when some events failed — per-event
// PerEventOk slots carry the granular result; the SQLite drain worker treats
// false slots as retry-please candidates.
}
catch (Exception ex)
{
_logger.Warning(ex, "Sidecar WriteAlarmEvents failed");
reply.Success = false;
reply.Error = ex.Message;
reply.PerEventOk = new bool[req.Events.Length];
}
await writer.WriteAsync(MessageKind.WriteAlarmEventsReply, reply, ct).ConfigureAwait(false);
}
private static HistorianSampleDto[] ToWire(List<HistorianSample> samples)
{
var dtos = new HistorianSampleDto[samples.Count];
for (var i = 0; i < samples.Count; i++)
{
var s = samples[i];
dtos[i] = new HistorianSampleDto
{
ValueBytes = s.Value is null ? null : MessagePackSerializer.Serialize(s.Value),
Quality = s.Quality,
TimestampUtcTicks = s.TimestampUtc.Ticks,
};
}
return dtos;
}
private static HistorianAggregateSampleDto[] ToWire(List<HistorianAggregateSample> samples)
{
var dtos = new HistorianAggregateSampleDto[samples.Count];
for (var i = 0; i < samples.Count; i++)
{
dtos[i] = new HistorianAggregateSampleDto
{
Value = samples[i].Value,
TimestampUtcTicks = samples[i].TimestampUtc.Ticks,
};
}
return dtos;
}
private static HistorianEventDto[] ToWire(List<Backend.HistorianEventDto> events)
{
var dtos = new HistorianEventDto[events.Count];
for (var i = 0; i < events.Count; i++)
{
var e = events[i];
dtos[i] = new HistorianEventDto
{
EventId = e.Id.ToString(),
Source = e.Source,
EventTimeUtcTicks = e.EventTime.Ticks,
ReceivedTimeUtcTicks = e.ReceivedTime.Ticks,
DisplayText = e.DisplayText,
Severity = e.Severity,
};
}
return dtos;
}
}
/// <summary>
/// Strategy for persisting alarm events into the Wonderware Alarm &amp; Events log. PR 3.W
/// supplies a real implementation that drives the aahClient SDK; PR 3.3 ships the
/// contract + a default null implementation so the sidecar can boot without one.
/// </summary>
public interface IAlarmEventWriter
{
/// <summary>
/// Writes a batch of alarm events. Returns one boolean per input event indicating
/// persisted vs. retry-please. The SQLite store-and-forward sink retries failed
/// slots on the next drain tick.
/// </summary>
Task<bool[]> WriteAsync(AlarmHistorianEventDto[] events, CancellationToken cancellationToken);
}
@@ -0,0 +1,36 @@
using System;
using System.IO.Pipes;
using System.Security.AccessControl;
using System.Security.Principal;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
/// <summary>
/// Builds a strict <see cref="PipeSecurity"/> for the historian sidecar pipe — only the
/// configured server-principal SID gets <c>ReadWrite | Synchronize</c>, LocalSystem is
/// explicitly denied (unless it's the allowed principal itself), and the allowed SID owns
/// the DACL. Mirrors the policy in Driver.Galaxy.Host's PipeAcl.
/// </summary>
public static class PipeAcl
{
public static PipeSecurity Create(SecurityIdentifier allowedSid)
{
if (allowedSid is null) throw new ArgumentNullException(nameof(allowedSid));
var security = new PipeSecurity();
security.AddAccessRule(new PipeAccessRule(
allowedSid,
PipeAccessRights.ReadWrite | PipeAccessRights.Synchronize,
AccessControlType.Allow));
var localSystem = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
if (allowedSid != localSystem)
security.AddAccessRule(new PipeAccessRule(localSystem, PipeAccessRights.FullControl, AccessControlType.Deny));
// Owner = allowed SID so the deny rules can't be removed without write-DACL rights.
security.SetOwner(allowedSid);
return security;
}
}
@@ -0,0 +1,165 @@
using System;
using System.IO.Pipes;
using System.Security.Principal;
using System.Threading;
using System.Threading.Tasks;
using MessagePack;
using Serilog;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
/// <summary>
/// Accepts one client connection at a time on a named pipe with the strict ACL from
/// <see cref="PipeAcl"/>. Verifies the peer SID and the per-process shared secret before
/// any frame is dispatched. Mirrors Driver.Galaxy.Host's PipeServer; the sidecar carries
/// its own copy so the deletion of Galaxy.Host in PR 7.2 leaves the sidecar self-contained.
/// </summary>
public sealed class PipeServer : IDisposable
{
private readonly string _pipeName;
private readonly SecurityIdentifier _allowedSid;
private readonly string _sharedSecret;
private readonly ILogger _logger;
private readonly CancellationTokenSource _cts = new();
private NamedPipeServerStream? _current;
public PipeServer(string pipeName, SecurityIdentifier allowedSid, string sharedSecret, ILogger logger)
{
_pipeName = pipeName ?? throw new ArgumentNullException(nameof(pipeName));
_allowedSid = allowedSid ?? throw new ArgumentNullException(nameof(allowedSid));
_sharedSecret = sharedSecret ?? throw new ArgumentNullException(nameof(sharedSecret));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Accepts one connection, performs Hello handshake, then dispatches frames to
/// <paramref name="handler"/> until EOF or cancel. Returns when the client disconnects.
/// </summary>
public async Task RunOneConnectionAsync(IFrameHandler handler, CancellationToken ct)
{
using var linked = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, ct);
var acl = PipeAcl.Create(_allowedSid);
_current = new NamedPipeServerStream(
_pipeName,
PipeDirection.InOut,
maxNumberOfServerInstances: 1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous,
inBufferSize: 64 * 1024,
outBufferSize: 64 * 1024,
pipeSecurity: acl);
try
{
await _current.WaitForConnectionAsync(linked.Token).ConfigureAwait(false);
using var reader = new FrameReader(_current, leaveOpen: true);
using var writer = new FrameWriter(_current, leaveOpen: true);
// First frame must be Hello with the correct shared secret. Reading it before
// the caller-SID impersonation check satisfies Windows' ERROR_CANNOT_IMPERSONATE
// rule — ImpersonateNamedPipeClient fails until at least one frame has been read.
var first = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false);
if (first is null || first.Value.Kind != MessageKind.Hello)
{
_logger.Warning("Sidecar IPC first frame was not Hello; dropping");
return;
}
if (!VerifyCaller(_current, out var reason))
{
_logger.Warning("Sidecar IPC caller rejected: {Reason}", reason);
_current.Disconnect();
return;
}
var hello = MessagePackSerializer.Deserialize<Hello>(first.Value.Body);
if (!string.Equals(hello.SharedSecret, _sharedSecret, StringComparison.Ordinal))
{
await writer.WriteAsync(MessageKind.HelloAck,
new HelloAck { Accepted = false, RejectReason = "shared-secret-mismatch" },
linked.Token).ConfigureAwait(false);
_logger.Warning("Sidecar IPC Hello rejected: shared-secret-mismatch");
return;
}
if (hello.ProtocolMajor != Hello.CurrentMajor)
{
await writer.WriteAsync(MessageKind.HelloAck,
new HelloAck { Accepted = false, RejectReason = $"major-version-mismatch-peer={hello.ProtocolMajor}-server={Hello.CurrentMajor}" },
linked.Token).ConfigureAwait(false);
_logger.Warning("Sidecar IPC Hello rejected: major mismatch peer={Peer} server={Server}",
hello.ProtocolMajor, Hello.CurrentMajor);
return;
}
await writer.WriteAsync(MessageKind.HelloAck,
new HelloAck { Accepted = true, HostName = Environment.MachineName },
linked.Token).ConfigureAwait(false);
while (!linked.Token.IsCancellationRequested)
{
var frame = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false);
if (frame is null) break;
await handler.HandleAsync(frame.Value.Kind, frame.Value.Body, writer, linked.Token).ConfigureAwait(false);
}
}
finally
{
_current.Dispose();
_current = null;
}
}
/// <summary>
/// Runs the server continuously, handling one connection at a time. When a connection
/// ends (clean or error), accepts the next.
/// </summary>
public async Task RunAsync(IFrameHandler handler, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try { await RunOneConnectionAsync(handler, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
catch (Exception ex) { _logger.Error(ex, "Sidecar IPC connection loop error — accepting next"); }
}
}
private bool VerifyCaller(NamedPipeServerStream pipe, out string reason)
{
try
{
pipe.RunAsClient(() =>
{
using var wi = WindowsIdentity.GetCurrent();
if (wi.User is null)
throw new InvalidOperationException("GetCurrent().User is null — cannot verify caller");
if (wi.User != _allowedSid)
throw new UnauthorizedAccessException(
$"caller SID {wi.User.Value} does not match allowed {_allowedSid.Value}");
});
reason = string.Empty;
return true;
}
catch (Exception ex) { reason = ex.Message; return false; }
}
public void Dispose()
{
_cts.Cancel();
_current?.Dispose();
_cts.Dispose();
}
}
/// <summary>
/// Strategy for handling each post-Hello frame the pipe server reads. Implementations
/// deserialize the body per the <see cref="MessageKind"/>, dispatch to the historian, and
/// write the corresponding reply through the supplied <see cref="FrameWriter"/>.
/// </summary>
public interface IFrameHandler
{
Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct);
}
@@ -0,0 +1,110 @@
using System;
using System.Security.Principal;
using System.Threading;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware;
/// <summary>
/// Entry point for the Wonderware Historian sidecar. Reads pipe name, allowed-SID,
/// shared secret, and historian connection config from environment (the supervisor
/// passes them at spawn time per <c>driver-stability.md</c>). Hosts a named-pipe server
/// dispatching the five sidecar contracts (PR 3.3) to the Wonderware Historian SDK.
/// </summary>
public static class Program
{
public static int Main(string[] args)
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.File(
@"%ProgramData%\OtOpcUa\historian-wonderware-.log".Replace("%ProgramData%", Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData)),
rollingInterval: RollingInterval.Day)
.CreateLogger();
try
{
var pipeName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_PIPE")
?? throw new InvalidOperationException("OTOPCUA_HISTORIAN_PIPE not set — supervisor must pass the sidecar pipe name");
var allowedSidValue = Environment.GetEnvironmentVariable("OTOPCUA_ALLOWED_SID")
?? throw new InvalidOperationException("OTOPCUA_ALLOWED_SID not set — supervisor must pass the server principal SID");
var sharedSecret = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SECRET")
?? throw new InvalidOperationException("OTOPCUA_HISTORIAN_SECRET not set — supervisor must pass the per-process secret at spawn time");
var allowedSid = new SecurityIdentifier(allowedSidValue);
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
// Sidecar can boot in "pipe-only" mode (no real Wonderware Historian SDK
// initialization) for smoke + IPC tests. Production sets ENABLED=true so the
// SDK opens its connection up front.
var historianEnabled = string.Equals(
Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_ENABLED"),
"true", StringComparison.OrdinalIgnoreCase);
if (!historianEnabled)
{
Log.Information("Wonderware historian sidecar starting in pipe-only mode (OTOPCUA_HISTORIAN_ENABLED!=true) — pipe={Pipe} allowedSid={Sid}", pipeName, allowedSidValue);
cts.Token.WaitHandle.WaitOne();
Log.Information("Wonderware historian sidecar stopping cleanly");
return 0;
}
using var historian = BuildHistorian();
var handler = new HistorianFrameHandler(historian, Log.Logger);
using var server = new PipeServer(pipeName, allowedSid, sharedSecret, Log.Logger);
Log.Information("Wonderware historian sidecar serving — pipe={Pipe} allowedSid={Sid}", pipeName, allowedSidValue);
try { server.RunAsync(handler, cts.Token).GetAwaiter().GetResult(); }
catch (OperationCanceledException) { /* clean shutdown via Ctrl-C */ }
Log.Information("Wonderware historian sidecar stopped cleanly");
return 0;
}
catch (Exception ex)
{
Log.Fatal(ex, "Wonderware historian sidecar fatal");
return 2;
}
finally { Log.CloseAndFlush(); }
}
/// <summary>
/// Builds the Wonderware Historian data source from environment variables. Mirrors
/// the env-var contract that <c>Driver.Galaxy.Host</c> used in v1; PR 3.W reaffirms
/// this contract in install scripts.
/// </summary>
private static HistorianDataSource BuildHistorian()
{
var cfg = new HistorianConfiguration
{
Enabled = true,
ServerName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVER") ?? "localhost",
Port = TryParseInt("OTOPCUA_HISTORIAN_PORT", 32568),
IntegratedSecurity = !string.Equals(Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_INTEGRATED"), "false", StringComparison.OrdinalIgnoreCase),
UserName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_USER"),
Password = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_PASS"),
CommandTimeoutSeconds = TryParseInt("OTOPCUA_HISTORIAN_TIMEOUT_SEC", 30),
MaxValuesPerRead = TryParseInt("OTOPCUA_HISTORIAN_MAX_VALUES", 10000),
FailureCooldownSeconds = TryParseInt("OTOPCUA_HISTORIAN_COOLDOWN_SEC", 60),
};
var servers = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVERS");
if (!string.IsNullOrWhiteSpace(servers))
cfg.ServerNames = new System.Collections.Generic.List<string>(
servers.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries));
Log.Information("Sidecar Historian config — {NodeCount} node(s), port={Port}",
cfg.ServerNames.Count > 0 ? cfg.ServerNames.Count : 1, cfg.Port);
return new HistorianDataSource(cfg);
}
private static int TryParseInt(string envName, int defaultValue)
{
var raw = Environment.GetEnvironmentVariable(envName);
return int.TryParse(raw, out var parsed) ? parsed : defaultValue;
}
}
@@ -0,0 +1,68 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net48</TargetFramework>
<!-- x86 to match the in-process bitness expectations of the Wonderware Historian SDK
that PR 3.2 lifted in. Mirrors Driver.Galaxy.Host's bitness for consistency. -->
<PlatformTarget>x86</PlatformTarget>
<Prefer32Bit>true</Prefer32Bit>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware</RootNamespace>
<AssemblyName>OtOpcUa.Driver.Historian.Wonderware</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MessagePack" Version="2.5.187"/>
<PackageReference Include="System.IO.Pipes.AccessControl" Version="5.0.0"/>
<PackageReference Include="System.Memory" Version="4.5.5"/>
<PackageReference Include="System.Threading.Tasks.Extensions" Version="4.5.4"/>
<PackageReference Include="System.Data.SqlClient" Version="4.9.0"/>
<PackageReference Include="Serilog" Version="4.2.0"/>
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests"/>
</ItemGroup>
<ItemGroup>
<!-- Wonderware Historian SDK — consumed by Backend/ for HistoryReadAsync.
Lifted from Driver.Galaxy.Host in PR 3.2 so the sidecar owns the SDK. -->
<Reference Include="aahClientManaged">
<HintPath>..\..\lib\aahClientManaged.dll</HintPath>
<EmbedInteropTypes>false</EmbedInteropTypes>
</Reference>
<Reference Include="aahClientCommon">
<HintPath>..\..\lib\aahClientCommon.dll</HintPath>
<EmbedInteropTypes>false</EmbedInteropTypes>
</Reference>
</ItemGroup>
<ItemGroup>
<!-- Historian SDK native and satellite DLLs — staged beside the host exe so the
aahClientManaged wrapper can P/Invoke into them without an AssemblyResolve hook. -->
<None Include="..\..\lib\aahClient.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="..\..\lib\Historian.CBE.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="..\..\lib\Historian.DPAPI.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="..\..\lib\ArchestrA.CloudHistorian.Contract.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>
@@ -0,0 +1,23 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// #152 — operator-visible snapshot of one auto-prohibited coalesced range. Returned in
/// bulk by <see cref="ModbusDriver.GetAutoProhibitedRanges"/>; consumers (Admin UI,
/// dashboards, log-aggregation pipelines) project the list into whatever shape they need.
/// </summary>
/// <param name="UnitId">Modbus unit ID (slave) the prohibition applies to.</param>
/// <param name="Region">Register region (HoldingRegisters / InputRegisters / Coils / DiscreteInputs).</param>
/// <param name="StartAddress">Inclusive start of the prohibited range (zero-based PDU offset).</param>
/// <param name="EndAddress">Inclusive end of the prohibited range. Equals <paramref name="StartAddress"/> when bisection has narrowed to a single register.</param>
/// <param name="LastProbedUtc">Wall-clock time of the most recent failure (record) or re-probe (refresh).</param>
/// <param name="BisectionPending">
/// True when the range still spans &gt; 1 register and the next re-probe will bisect it
/// (per #150). False when the range is single-register or has been pinned permanent.
/// </param>
public sealed record ModbusAutoProhibition(
byte UnitId,
ModbusRegion Region,
ushort StartAddress,
ushort EndAddress,
DateTime LastProbedUtc,
bool BisectionPending);
@@ -1,5 +1,7 @@
using System.Buffers.Binary;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
@@ -57,12 +59,16 @@ public sealed class ModbusDriver
private DriverHealth _health = new(DriverState.Unknown, null, null);
private readonly Dictionary<string, ModbusTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly ILogger<ModbusDriver> _logger;
public ModbusDriver(ModbusDriverOptions options, string driverInstanceId,
Func<ModbusDriverOptions, IModbusTransport>? transportFactory = null)
Func<ModbusDriverOptions, IModbusTransport>? transportFactory = null,
ILogger<ModbusDriver>? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
_logger = logger ?? NullLogger<ModbusDriver>.Instance;
_transportFactory = transportFactory
?? (o => new ModbusTcpTransport(
o.Host, o.Port, o.Timeout, o.AutoReconnect,
@@ -409,7 +415,19 @@ public sealed class ModbusDriver
/// Cleared by ReinitializeAsync (operator restart) or by an explicit re-probe API
/// (not yet shipped).
/// </summary>
private readonly Dictionary<(byte Unit, ModbusRegion Region, ushort Start, ushort End), DateTime> _autoProhibited = new();
/// <summary>
/// #150 — per-prohibition state. <c>SplitPending</c> drives the re-probe loop's
/// bisection: when true and the range spans &gt; 1 register, the next re-probe
/// tries the two halves separately to narrow the actual offending register(s).
/// Single-register prohibitions can't be split further; they stay re-probed as-is.
/// </summary>
private sealed class ProhibitionState
{
public DateTime LastProbedUtc;
public bool SplitPending;
}
private readonly Dictionary<(byte Unit, ModbusRegion Region, ushort Start, ushort End), ProhibitionState> _autoProhibited = new();
private readonly object _autoProhibitedLock = new();
private CancellationTokenSource? _reprobeCts;
@@ -431,7 +449,53 @@ public sealed class ModbusDriver
private void RecordAutoProhibition(byte unit, ModbusRegion region, ushort start, ushort end)
{
lock (_autoProhibitedLock) _autoProhibited[(unit, region, start, end)] = DateTime.UtcNow;
bool isNew;
lock (_autoProhibitedLock)
{
// Multi-register prohibitions enter the bisection workflow on the next re-probe;
// single-register prohibitions are already minimal and skip bisection.
isNew = !_autoProhibited.ContainsKey((unit, region, start, end));
_autoProhibited[(unit, region, start, end)] = new ProhibitionState
{
LastProbedUtc = DateTime.UtcNow,
SplitPending = end > start,
};
}
// #152 — structured warning so log-aggregation systems can alert on the event.
// First-time prohibitions get logged; re-fires of the same range stay quiet to avoid
// flooding when a per-tick exception keeps the same range bad. The state visible via
// GetAutoProhibitedRanges shows operators the long-tail picture.
if (isNew)
_logger.LogWarning(
"Modbus coalesced read failed; auto-prohibited range recorded. Driver={DriverInstanceId} Unit={Unit} Region={Region} Start={Start} End={End} Span={Span}",
_driverInstanceId, unit, region, start, end, end - start + 1);
}
/// <summary>
/// #153 — info log when a re-probe clears a prohibition. Operators see recovery
/// events without having to poll <see cref="GetAutoProhibitedRanges"/>.
/// </summary>
private void LogProhibitionCleared(byte unit, ModbusRegion region, ushort start, ushort end) =>
_logger.LogInformation(
"Modbus auto-prohibition cleared by re-probe. Driver={DriverInstanceId} Unit={Unit} Region={Region} Start={Start} End={End}",
_driverInstanceId, unit, region, start, end);
/// <summary>
/// #152 — operator-visible snapshot of every range the planner has learned to read
/// individually. Exposed through the driver-diagnostics surface; consumers (Admin UI,
/// log-aggregation, dashboards) call this to show what's been auto-isolated. Populated
/// on coalesced-read failure (#148), narrowed by bisection (#150), cleared by the
/// re-probe loop (#151) when ranges become healthy again.
/// </summary>
public IReadOnlyList<ModbusAutoProhibition> GetAutoProhibitedRanges()
{
lock (_autoProhibitedLock)
return _autoProhibited
.Select(kv => new ModbusAutoProhibition(
kv.Key.Unit, kv.Key.Region, kv.Key.Start, kv.Key.End,
kv.Value.LastProbedUtc, kv.Value.SplitPending))
.ToArray();
}
/// <summary>Test/diagnostic accessor — returns the current auto-prohibited range count.</summary>
@@ -441,78 +505,132 @@ public sealed class ModbusDriver
}
/// <summary>
/// #151 — periodic re-probe loop. Wakes every <c>AutoProhibitReprobeInterval</c> and
/// retries each auto-prohibited range with a one-shot coalesced read. Successful
/// re-probes drop the prohibition; failed ones leave it in place + bump the
/// last-probed timestamp so the next attempt waits another full interval.
/// Lives for the driver lifetime; cancelled by <c>ShutdownAsync</c>.
/// #151 — periodic re-probe loop, augmented in #150 with bisection-style narrowing.
/// Each tick processes every prohibition: split-pending multi-register ranges get
/// bisected (try left + right halves; replace with whichever halves still fail),
/// single-register or non-split-pending ranges get a straight re-probe. Lives for
/// the driver lifetime; cancelled by <c>ShutdownAsync</c>.
/// </summary>
private async Task ReprobeLoopAsync(CancellationToken ct)
{
var interval = _options.AutoProhibitReprobeInterval!.Value;
var transport = _transport;
while (!ct.IsCancellationRequested)
{
try { await Task.Delay(interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
if (transport is null) continue;
// Snapshot the prohibition set so we can release the lock during the wire calls.
(byte Unit, ModbusRegion Region, ushort Start, ushort End)[] candidates;
lock (_autoProhibitedLock)
candidates = _autoProhibited.Keys.ToArray();
foreach (var p in candidates)
{
if (ct.IsCancellationRequested) return;
var fc = p.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
var qty = (ushort)(p.End - p.Start + 1);
try
{
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
probeCts.CancelAfter(_options.Timeout);
_ = await ReadRegisterBlockAsync(transport, p.Unit, fc, p.Start, qty, probeCts.Token).ConfigureAwait(false);
// Range is healthy now — drop the prohibition. Next data scan re-coalesces normally.
lock (_autoProhibitedLock) _autoProhibited.Remove(p);
}
try { await RunReprobeOnceForTestAsync(ct).ConfigureAwait(false); }
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
catch
{
// Still bad. Bump the timestamp so it shows up on diagnostics as recently
// re-probed — the prohibition stays in place.
lock (_autoProhibitedLock)
{
if (_autoProhibited.ContainsKey(p))
_autoProhibited[p] = DateTime.UtcNow;
}
}
}
}
}
/// <summary>Test/diagnostic accessor — fires one re-probe pass synchronously for tests.</summary>
/// <summary>
/// One re-probe pass. Public-but-internal so tests can drive it synchronously rather
/// than wait on the background timer. Iterates a snapshot of the prohibition set; for
/// each entry decides between bisection (multi-register + SplitPending) or straight
/// retry (single-register or already-narrowed).
/// </summary>
internal async Task RunReprobeOnceForTestAsync(CancellationToken ct)
{
var transport = _transport ?? throw new InvalidOperationException("Transport not connected");
(byte Unit, ModbusRegion Region, ushort Start, ushort End)[] candidates;
lock (_autoProhibitedLock) candidates = _autoProhibited.Keys.ToArray();
foreach (var p in candidates)
((byte Unit, ModbusRegion Region, ushort Start, ushort End) Key, bool SplitPending)[] candidates;
lock (_autoProhibitedLock)
candidates = _autoProhibited
.Select(kv => (Key: kv.Key, SplitPending: kv.Value.SplitPending))
.ToArray();
foreach (var (key, splitPending) in candidates)
{
var fc = p.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
var qty = (ushort)(p.End - p.Start + 1);
if (ct.IsCancellationRequested) return;
if (splitPending && key.End > key.Start)
await BisectAndReprobeAsync(transport, key, ct).ConfigureAwait(false);
else
await StraightReprobeAsync(transport, key, ct).ConfigureAwait(false);
}
}
private async Task StraightReprobeAsync(IModbusTransport transport,
(byte Unit, ModbusRegion Region, ushort Start, ushort End) key, CancellationToken ct)
{
var fc = key.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
var qty = (ushort)(key.End - key.Start + 1);
try
{
_ = await ReadRegisterBlockAsync(transport, p.Unit, fc, p.Start, qty, ct).ConfigureAwait(false);
lock (_autoProhibitedLock) _autoProhibited.Remove(p);
_ = await ReadRegisterBlockAsync(transport, key.Unit, fc, key.Start, qty, ct).ConfigureAwait(false);
lock (_autoProhibitedLock) _autoProhibited.Remove(key);
LogProhibitionCleared(key.Unit, key.Region, key.Start, key.End);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch
{
lock (_autoProhibitedLock)
if (_autoProhibited.ContainsKey(p))
_autoProhibited[p] = DateTime.UtcNow;
if (_autoProhibited.TryGetValue(key, out var st)) st.LastProbedUtc = DateTime.UtcNow;
}
}
/// <summary>
/// #150 — bisect a multi-register prohibition. Removes the parent entry and re-adds
/// whichever halves still fail. Over multiple re-probe ticks the prohibition narrows
/// log2(span) times until it pinpoints the actual protected register(s).
/// </summary>
private async Task BisectAndReprobeAsync(IModbusTransport transport,
(byte Unit, ModbusRegion Region, ushort Start, ushort End) key, CancellationToken ct)
{
var fc = key.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
var mid = (ushort)((key.Start + key.End) / 2);
var leftEnd = mid;
var rightStart = (ushort)(mid + 1);
var leftFailed = await ProbeFailsAsync(transport, fc, key.Unit, key.Start, leftEnd, ct).ConfigureAwait(false);
var rightFailed = await ProbeFailsAsync(transport, fc, key.Unit, rightStart, key.End, ct).ConfigureAwait(false);
lock (_autoProhibitedLock)
{
_autoProhibited.Remove(key);
if (leftFailed)
{
_autoProhibited[(key.Unit, key.Region, key.Start, leftEnd)] = new ProhibitionState
{
LastProbedUtc = DateTime.UtcNow,
SplitPending = leftEnd > key.Start,
};
}
if (rightFailed)
{
_autoProhibited[(key.Unit, key.Region, rightStart, key.End)] = new ProhibitionState
{
LastProbedUtc = DateTime.UtcNow,
SplitPending = key.End > rightStart,
};
}
// Both halves succeeded → entry is just removed. The parent prohibition is gone
// and the next normal scan can re-coalesce across the whole original range.
}
// #153 — log per-half outcome OUTSIDE the lock (logger calls can be expensive).
// Both halves clear → emit a single combined "fully cleared" line.
if (!leftFailed && !rightFailed)
LogProhibitionCleared(key.Unit, key.Region, key.Start, key.End);
else
{
if (!leftFailed)
LogProhibitionCleared(key.Unit, key.Region, key.Start, leftEnd);
if (!rightFailed)
LogProhibitionCleared(key.Unit, key.Region, rightStart, key.End);
}
}
private async Task<bool> ProbeFailsAsync(IModbusTransport transport, byte fc, byte unit,
ushort start, ushort end, CancellationToken ct)
{
var qty = (ushort)(end - start + 1);
try
{
_ = await ReadRegisterBlockAsync(transport, unit, fc, start, qty, ct).ConfigureAwait(false);
return false;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch { return true; }
}
/// <summary>
@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
@@ -14,13 +15,25 @@ public static class ModbusDriverFactoryExtensions
{
public const string DriverTypeName = "Modbus";
public static void Register(DriverFactoryRegistry registry)
/// <summary>
/// Register the Modbus factory with the driver registry. The optional
/// <paramref name="loggerFactory"/> is captured at registration time and used to
/// construct an <see cref="ILogger{ModbusDriver}"/> per driver instance — without it,
/// the driver runs with the null logger (existing tests and standalone callers stay
/// unchanged).
/// </summary>
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
{
ArgumentNullException.ThrowIfNull(registry);
registry.Register(DriverTypeName, CreateInstance);
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
}
internal static ModbusDriver CreateInstance(string driverInstanceId, string driverConfigJson)
/// <summary>Public for the Server-side bootstrapper + test consumers (Admin.Tests, etc.).</summary>
public static ModbusDriver CreateInstance(string driverInstanceId, string driverConfigJson)
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
/// <summary>Logger-aware overload — used by <see cref="Register"/>'s closure when wired through DI.</summary>
public static ModbusDriver CreateInstance(string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
@@ -46,6 +59,7 @@ public static class ModbusDriverFactoryExtensions
UseFC16ForSingleRegisterWrites = dto.UseFC16ForSingleRegisterWrites ?? false,
DisableFC23 = dto.DisableFC23 ?? false,
WriteOnChangeOnly = dto.WriteOnChangeOnly ?? false,
MaxReadGap = dto.MaxReadGap ?? 0,
Family = dto.Family is null ? ModbusFamily.Generic
: ParseEnum<ModbusFamily>(dto.Family, "<driver-level>", driverInstanceId, "Family"),
MelsecSubFamily = dto.MelsecSubFamily is null ? MelsecFamily.Q_L_iQR
@@ -83,7 +97,10 @@ public static class ModbusDriverFactoryExtensions
},
};
return new ModbusDriver(options, driverInstanceId);
return new ModbusDriver(
options, driverInstanceId,
transportFactory: null,
logger: loggerFactory?.CreateLogger<ModbusDriver>());
}
private static ModbusTagDefinition BuildTag(ModbusTagDto t, string driverInstanceId)
@@ -174,6 +191,7 @@ public static class ModbusDriverFactoryExtensions
public bool? UseFC16ForSingleRegisterWrites { get; init; }
public bool? DisableFC23 { get; init; }
public bool? WriteOnChangeOnly { get; init; }
public ushort? MaxReadGap { get; init; }
public string? Family { get; init; }
public string? MelsecSubFamily { get; init; }
public int? AutoProhibitReprobeMs { get; init; }
@@ -0,0 +1,289 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server.Alarms;
/// <summary>
/// Server-level alarm-condition state machine. Tracks one entry per registered
/// condition; consumes value changes from the four sub-attribute references in
/// <see cref="AlarmConditionInfo"/> (InAlarm / Priority / Description / Acked) and
/// raises <see cref="TransitionRaised"/> on Active / Acknowledged / Inactive
/// transitions per OPC UA Part 9 (simplified). Operator acknowledgement routes
/// through <see cref="IAlarmAcknowledger"/> against <c>AckMsgWriteRef</c>.
/// </summary>
/// <remarks>
/// This is the driver-agnostic replacement for <c>GalaxyAlarmTracker</c>. The
/// service does not own subscription lifecycle — PR 2.3 will wire DriverNodeManager
/// to subscribe through the driver's <c>ISubscribable</c> and forward value changes
/// here via <see cref="OnValueChanged"/>. Keeping the service free of subscription
/// plumbing makes it trivially testable and lets future drivers feed it from any
/// value source (in-process, gRPC, named pipe).
/// </remarks>
public sealed class AlarmConditionService : IDisposable
{
private readonly Func<DateTime> _clock;
// ConditionId → state.
private readonly ConcurrentDictionary<string, AlarmConditionState> _conditions =
new(StringComparer.OrdinalIgnoreCase);
// Sub-attribute full ref → (conditionId, which field). Multiple conditions may
// observe the same sub-attribute (rare but legal); the value is a list to support
// fan-out on a single value change.
private readonly ConcurrentDictionary<string, List<(string ConditionId, AlarmField Field)>> _refToCondition =
new(StringComparer.OrdinalIgnoreCase);
private readonly object _refMapLock = new();
private bool _disposed;
/// <summary>
/// Fired when a registered condition transitions Active / Acknowledged / Inactive.
/// Handlers must be cheap; the event is raised on whatever thread feeds
/// <see cref="OnValueChanged"/> and blocks the value-change pipeline.
/// </summary>
public event EventHandler<AlarmConditionTransition>? TransitionRaised;
public AlarmConditionService() : this(() => DateTime.UtcNow) { }
/// <summary>Test seam — inject a fixed clock for deterministic transition timestamps.</summary>
internal AlarmConditionService(Func<DateTime> clock)
{
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
}
/// <summary>Number of currently tracked conditions. Diagnostic only.</summary>
public int TrackedCount => _conditions.Count;
/// <summary>
/// Register a condition. Idempotent — repeat calls for the same
/// <paramref name="conditionId"/> are a no-op. The acker is captured for the
/// condition's lifetime; pass null when the driver does not accept acks.
/// </summary>
public void Track(string conditionId, AlarmConditionInfo info, IAlarmAcknowledger? acker = null)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentException.ThrowIfNullOrWhiteSpace(conditionId);
ArgumentNullException.ThrowIfNull(info);
var state = new AlarmConditionState(conditionId, info, acker);
if (!_conditions.TryAdd(conditionId, state)) return;
lock (_refMapLock)
{
AddRefMapping(info.InAlarmRef, conditionId, AlarmField.InAlarm);
AddRefMapping(info.PriorityRef, conditionId, AlarmField.Priority);
AddRefMapping(info.DescAttrNameRef, conditionId, AlarmField.DescAttrName);
AddRefMapping(info.AckedRef, conditionId, AlarmField.Acked);
}
}
/// <summary>Deregister a condition. No-op when not tracked.</summary>
public void Untrack(string conditionId)
{
if (_disposed) return;
if (!_conditions.TryRemove(conditionId, out var state)) return;
lock (_refMapLock)
{
RemoveRefMapping(state.Info.InAlarmRef, conditionId);
RemoveRefMapping(state.Info.PriorityRef, conditionId);
RemoveRefMapping(state.Info.DescAttrNameRef, conditionId);
RemoveRefMapping(state.Info.AckedRef, conditionId);
}
}
/// <summary>
/// Returns the set of sub-attribute references the service currently needs
/// subscribed. Callers wire one subscription per ref through the driver's
/// <see cref="ISubscribable"/>; PR 2.3 owns that wiring.
/// </summary>
public IReadOnlyCollection<string> GetSubscribedReferences()
{
lock (_refMapLock) return [.. _refToCondition.Keys];
}
/// <summary>
/// Operator acknowledgement entry point. Returns false when the condition is
/// not tracked, the condition has no acker registered, the condition has no
/// <c>AckMsgWriteRef</c>, or the acker reports the write failed.
/// </summary>
public Task<bool> AcknowledgeAsync(string conditionId, string comment, CancellationToken cancellationToken = default)
{
if (_disposed || !_conditions.TryGetValue(conditionId, out var state))
return Task.FromResult(false);
if (state.Acker is null || string.IsNullOrEmpty(state.Info.AckMsgWriteRef))
return Task.FromResult(false);
return state.Acker.WriteAckMessageAsync(state.Info.AckMsgWriteRef, comment ?? string.Empty, cancellationToken);
}
/// <summary>
/// Snapshot every tracked condition's current state. Diagnostic / dashboard use only.
/// </summary>
public IReadOnlyList<AlarmConditionSnapshot> Snapshot()
{
return [.. _conditions.Values.Select(s =>
{
lock (s.Lock)
return new AlarmConditionSnapshot(s.ConditionId, s.InAlarm, s.Acked, s.Priority, s.Description);
})];
}
/// <summary>
/// Feed a value change for one of the registered sub-attribute references.
/// The service runs the state machine and raises <see cref="TransitionRaised"/>
/// when the change produces a lifecycle transition. Unknown references are
/// silently dropped — the caller may register and unregister concurrently with
/// value-change delivery, and a stale callback for a recently-untracked
/// condition must not throw.
/// </summary>
public void OnValueChanged(string fullReference, DataValueSnapshot value)
{
if (_disposed) return;
if (string.IsNullOrEmpty(fullReference)) return;
List<(string ConditionId, AlarmField Field)>? targets;
lock (_refMapLock)
{
if (!_refToCondition.TryGetValue(fullReference, out targets) || targets.Count == 0) return;
// Snapshot under lock; the state machine runs outside.
targets = [.. targets];
}
var now = _clock();
foreach (var (conditionId, field) in targets)
{
if (!_conditions.TryGetValue(conditionId, out var state)) continue;
AlarmConditionTransition? transition = null;
lock (state.Lock)
{
transition = ApplyValue(state, field, value, now);
}
if (transition is { } t)
{
TransitionRaised?.Invoke(this, t);
}
}
}
/// <summary>
/// Apply one value change to one condition. Returns a transition when the
/// change crosses a state boundary; null otherwise. Caller holds <c>state.Lock</c>.
/// </summary>
private static AlarmConditionTransition? ApplyValue(
AlarmConditionState state, AlarmField field, DataValueSnapshot value, DateTime now)
{
AlarmConditionTransition? transition = null;
state.LastUpdateUtc = now;
switch (field)
{
case AlarmField.InAlarm:
{
var wasActive = state.InAlarm;
var isActive = value.Value is bool b && b;
state.InAlarm = isActive;
if (!wasActive && isActive)
{
// Reset Acked on every active transition so a re-alarm requires fresh ack.
state.Acked = false;
transition = new AlarmConditionTransition(
state.ConditionId, AlarmStateTransition.Active,
state.Priority, state.Description, now);
}
else if (wasActive && !isActive)
{
transition = new AlarmConditionTransition(
state.ConditionId, AlarmStateTransition.Inactive,
state.Priority, state.Description, now);
}
break;
}
case AlarmField.Priority:
state.Priority = CoercePriority(value.Value, state.Priority);
break;
case AlarmField.DescAttrName:
state.Description = value.Value as string;
break;
case AlarmField.Acked:
{
var wasAcked = state.Acked;
var isAcked = value.Value is bool b && b;
state.Acked = isAcked;
// Only fire Acknowledged on false → true while still active. The first
// post-Track callback often arrives with isAcked == wasAcked (state starts
// Acked=true so an initially-quiet alarm doesn't misfire).
if (!wasAcked && isAcked && state.InAlarm)
{
transition = new AlarmConditionTransition(
state.ConditionId, AlarmStateTransition.Acknowledged,
state.Priority, state.Description, now);
}
break;
}
}
return transition;
}
private static int CoercePriority(object? raw, int fallback) => raw switch
{
int i => i,
short s => s,
long l when l <= int.MaxValue => (int)l,
byte b => b,
ushort us => us,
uint ui when ui <= int.MaxValue => (int)ui,
_ => fallback,
};
private void AddRefMapping(string? fullRef, string conditionId, AlarmField field)
{
if (string.IsNullOrEmpty(fullRef)) return;
if (!_refToCondition.TryGetValue(fullRef, out var list))
{
list = [];
_refToCondition[fullRef] = list;
}
list.Add((conditionId, field));
}
private void RemoveRefMapping(string? fullRef, string conditionId)
{
if (string.IsNullOrEmpty(fullRef)) return;
if (!_refToCondition.TryGetValue(fullRef, out var list)) return;
list.RemoveAll(t => string.Equals(t.ConditionId, conditionId, StringComparison.OrdinalIgnoreCase));
if (list.Count == 0) _refToCondition.TryRemove(fullRef, out _);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_conditions.Clear();
lock (_refMapLock) _refToCondition.Clear();
}
private enum AlarmField { InAlarm, Priority, DescAttrName, Acked }
/// <summary>Per-condition mutable state. Access guarded by <see cref="Lock"/>.</summary>
private sealed class AlarmConditionState(string conditionId, AlarmConditionInfo info, IAlarmAcknowledger? acker)
{
public readonly object Lock = new();
public string ConditionId { get; } = conditionId;
public AlarmConditionInfo Info { get; } = info;
public IAlarmAcknowledger? Acker { get; } = acker;
public bool InAlarm;
// Default Acked=true so the first post-Track callback (.Acked=true on a quiet
// alarm) doesn't misfire as a transition. Active sets it back to false.
public bool Acked = true;
public int Priority;
public string? Description;
public DateTime LastUpdateUtc;
}
}
@@ -0,0 +1,44 @@
namespace ZB.MOM.WW.OtOpcUa.Server.Alarms;
/// <summary>
/// Lifecycle transition for an alarm condition. Mirrors OPC UA Part 9 alarm states
/// simplified to the active / acknowledged / inactive triplet that every driver in
/// the repo exposes today.
/// </summary>
public enum AlarmStateTransition
{
/// <summary>InAlarm flipped false → true. Default to unacknowledged.</summary>
Active,
/// <summary>Acked flipped false → true while the alarm is still active.</summary>
Acknowledged,
/// <summary>InAlarm flipped true → false.</summary>
Inactive,
}
/// <summary>
/// One alarm-state transition raised by <see cref="AlarmConditionService.TransitionRaised"/>.
/// </summary>
/// <param name="ConditionId">Stable identifier the caller registered the condition under (typically the driver's alarm full reference).</param>
/// <param name="Transition">Which state the alarm transitioned to.</param>
/// <param name="Priority">Latest known priority. 0 when no priority sub-attribute was registered or no value has been observed yet.</param>
/// <param name="Description">Latest known description text; null when not registered or not yet observed.</param>
/// <param name="AtUtc">Server-clock UTC of the value change that produced this transition.</param>
public sealed record AlarmConditionTransition(
string ConditionId,
AlarmStateTransition Transition,
int Priority,
string? Description,
DateTime AtUtc);
/// <summary>
/// Read-only snapshot of an alarm condition's current state. Used for diagnostics
/// and dashboards; not part of the live transition stream.
/// </summary>
public sealed record AlarmConditionSnapshot(
string ConditionId,
bool InAlarm,
bool Acked,
int Priority,
string? Description);
@@ -0,0 +1,23 @@
namespace ZB.MOM.WW.OtOpcUa.Server.Alarms;
/// <summary>
/// Strategy for routing operator acknowledgement writes back to the underlying driver.
/// Decouples <see cref="AlarmConditionService"/> from any specific driver's write API
/// so the service can be tested without a real driver and reused across drivers with
/// different write paths.
/// </summary>
/// <remarks>
/// PR 2.3 supplies a default implementation that writes through the driver's
/// <c>IWritable.WriteAsync</c> using the <c>AckMsgWriteRef</c> from
/// <c>AlarmConditionInfo</c>. Drivers that route acks differently (e.g. a dedicated
/// RPC) can supply a custom implementation when registering the condition.
/// </remarks>
public interface IAlarmAcknowledger
{
/// <summary>
/// Writes the operator's <paramref name="comment"/> to <paramref name="ackMsgWriteRef"/>.
/// Returns true on driver-reported success, false otherwise. Implementations should
/// propagate cancellation but never throw on a write that the driver cleanly rejects.
/// </summary>
Task<bool> WriteAckMessageAsync(string ackMsgWriteRef, string comment, CancellationToken cancellationToken);
}
@@ -0,0 +1,71 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server.History;
/// <summary>
/// Default <see cref="IHistoryRouter"/> implementation.
/// </summary>
public sealed class HistoryRouter : IHistoryRouter
{
private readonly ConcurrentDictionary<string, IHistorianDataSource> _registry =
new(StringComparer.OrdinalIgnoreCase);
private bool _disposed;
/// <inheritdoc />
public void Register(string fullReferencePrefix, IHistorianDataSource source)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(fullReferencePrefix);
ArgumentNullException.ThrowIfNull(source);
if (!_registry.TryAdd(fullReferencePrefix, source))
{
throw new InvalidOperationException(
$"A historian data source is already registered for prefix '{fullReferencePrefix}'.");
}
}
/// <inheritdoc />
public IHistorianDataSource? Resolve(string fullReference)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(fullReference);
// Longest-prefix match. Sources are typically a handful per server, so a linear
// scan is fine and avoids building a trie for a low-cardinality registry.
IHistorianDataSource? best = null;
var bestPrefixLength = -1;
foreach (var (prefix, source) in _registry)
{
if (fullReference.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
&& prefix.Length > bestPrefixLength)
{
best = source;
bestPrefixLength = prefix.Length;
}
}
return best;
}
/// <summary>
/// Disposes every registered source and prevents further registrations or
/// resolutions. Sources may not all be disposable — null-safe disposal pattern.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
foreach (var source in _registry.Values)
{
try { source.Dispose(); }
catch { /* best-effort — server shutdown should not throw on a misbehaving source */ }
}
_registry.Clear();
}
}
@@ -0,0 +1,37 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server.History;
/// <summary>
/// Server-level routing of OPC UA HistoryRead service calls to a registered
/// <see cref="IHistorianDataSource"/>. One router per server instance; sources are
/// registered at startup keyed by a driver-side full-reference prefix (typically the
/// driver instance id).
/// </summary>
/// <remarks>
/// <para>
/// The router decouples history availability from the driver lifecycle: a driver
/// can restart (or be temporarily disconnected) without taking history offline,
/// and a single historian can serve nodes from multiple drivers.
/// </para>
/// <para>
/// Resolution is by longest-prefix match so a per-driver source registered under
/// <c>"galaxy"</c> wins over a fallback registered under empty string.
/// </para>
/// </remarks>
public interface IHistoryRouter : IDisposable
{
/// <summary>
/// Resolves a full reference to its registered data source, or null when no source
/// covers it.
/// </summary>
IHistorianDataSource? Resolve(string fullReference);
/// <summary>
/// Registers a data source for full references that start with
/// <paramref name="fullReferencePrefix"/>. Throws when the prefix is already
/// registered — duplicate registrations indicate a startup-config bug rather than
/// a runtime concern.
/// </summary>
void Register(string fullReferencePrefix, IHistorianDataSource source);
}
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Core.Observability;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
namespace ZB.MOM.WW.OtOpcUa.Server.Observability;
@@ -85,6 +86,13 @@ public sealed class HealthEndpointsHost : IAsyncDisposable
await WriteReadyzAsync(ctx).ConfigureAwait(false);
break;
default:
// #154 — driver-diagnostics path family. URL shape:
// /diagnostics/drivers/{driverInstanceId}/modbus/auto-prohibited
// Driver-agnostic at the URL level so future driver types (S7, AbCip,
// FOCAS) can add their own per-type subpaths.
if (path.StartsWith("/diagnostics/drivers/", StringComparison.Ordinal))
await WriteDriverDiagnosticsAsync(ctx, path).ConfigureAwait(false);
else
ctx.Response.StatusCode = 404;
break;
}
@@ -157,6 +165,64 @@ public sealed class HealthEndpointsHost : IAsyncDisposable
return list;
}
/// <summary>
/// #154 — driver-diagnostics endpoint family. Routes
/// <c>/diagnostics/drivers/{driverId}/modbus/auto-prohibited</c> to the live
/// <see cref="ModbusDriver"/> instance's <see cref="ModbusDriver.GetAutoProhibitedRanges"/>.
/// 404 when the driver instance doesn't exist; 400 when it exists but isn't a Modbus
/// driver (the per-type endpoint is wrong for this row).
/// </summary>
private async Task WriteDriverDiagnosticsAsync(HttpListenerContext ctx, string path)
{
// Path shape: /diagnostics/drivers/{id}/modbus/auto-prohibited
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length < 4 || segments[0] != "diagnostics" || segments[1] != "drivers")
{
ctx.Response.StatusCode = 404;
return;
}
var driverId = segments[2];
var driver = _driverHost.GetDriver(driverId);
if (driver is null)
{
ctx.Response.StatusCode = 404;
await WriteBodyAsync(ctx, JsonSerializer.Serialize(new { error = $"Driver '{driverId}' not found" })).ConfigureAwait(false);
return;
}
// Per-driver-type subpath dispatch. Today only Modbus is wired; future drivers add
// their own segments[3] cases.
if (segments.Length >= 5 && segments[3] == "modbus" && segments[4] == "auto-prohibited")
{
if (driver is not ModbusDriver modbus)
{
ctx.Response.StatusCode = 400;
await WriteBodyAsync(ctx, JsonSerializer.Serialize(new { error = $"Driver '{driverId}' is not a Modbus driver (type: {driver.DriverType})" })).ConfigureAwait(false);
return;
}
var ranges = modbus.GetAutoProhibitedRanges();
ctx.Response.StatusCode = 200;
await WriteBodyAsync(ctx, JsonSerializer.Serialize(new
{
driverInstanceId = driverId,
count = ranges.Count,
ranges = ranges.Select(r => new
{
unitId = r.UnitId,
region = r.Region.ToString(),
startAddress = r.StartAddress,
endAddress = r.EndAddress,
lastProbedUtc = r.LastProbedUtc,
bisectionPending = r.BisectionPending,
}).ToArray(),
})).ConfigureAwait(false);
return;
}
ctx.Response.StatusCode = 404;
}
private static async Task WriteBodyAsync(HttpListenerContext ctx, string body)
{
var bytes = Encoding.UTF8.GetBytes(body);
@@ -5,6 +5,8 @@ using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
using ZB.MOM.WW.OtOpcUa.Server.Alarms;
using ZB.MOM.WW.OtOpcUa.Server.History;
using ZB.MOM.WW.OtOpcUa.Server.Security;
using DriverWriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest;
// Core.Abstractions defines a type-named HistoryReadResult (driver-side samples + continuation
@@ -85,10 +87,31 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
private readonly IReadable? _virtualReadable;
private readonly IReadable? _scriptedAlarmReadable;
// PR 1.3 — server-level history routing. When non-null + a source is registered for
// the requested full reference, the four HistoryRead* overrides dispatch through the
// router. Otherwise we fall back to the legacy `_driver as IHistoryProvider` path
// wrapped in a thin adapter, so existing tests and drivers that still implement
// IHistoryProvider directly keep working until PR 1.W flips DI to register the
// legacy path inside the router.
private readonly IHistoryRouter? _historyRouter;
private LegacyDriverHistoryAdapter? _legacyHistoryAdapter;
// PR 2.3 — server-level alarm-condition state machine. When non-null, every
// MarkAsAlarmCondition call also registers the condition with the service so the
// server runs the Active/Acknowledged/Inactive transitions itself instead of
// relying on the driver's own tracker. _conditionSinks maps conditionId →
// ConditionSink so service-raised transitions reach the right OPC UA AlarmCondition
// sibling. Legacy IAlarmSource path keeps working in parallel until PR 7.2.
private readonly AlarmConditionService? _alarmService;
private readonly Dictionary<string, ConditionSink> _conditionSinks = new(StringComparer.OrdinalIgnoreCase);
private EventHandler<AlarmConditionTransition>? _alarmTransitionHandler;
public DriverNodeManager(IServerInternal server, ApplicationConfiguration configuration,
IDriver driver, CapabilityInvoker invoker, ILogger<DriverNodeManager> logger,
AuthorizationGate? authzGate = null, NodeScopeResolver? scopeResolver = null,
IReadable? virtualReadable = null, IReadable? scriptedAlarmReadable = null)
IReadable? virtualReadable = null, IReadable? scriptedAlarmReadable = null,
IHistoryRouter? historyRouter = null,
AlarmConditionService? alarmService = null)
: base(server, configuration, namespaceUris: $"urn:OtOpcUa:{driver.DriverInstanceId}")
{
_driver = driver;
@@ -100,7 +123,117 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
_scopeResolver = scopeResolver;
_virtualReadable = virtualReadable;
_scriptedAlarmReadable = scriptedAlarmReadable;
_historyRouter = historyRouter;
_alarmService = alarmService;
_logger = logger;
if (_alarmService is not null)
{
_alarmTransitionHandler = OnAlarmServiceTransition;
_alarmService.TransitionRaised += _alarmTransitionHandler;
}
}
/// <summary>
/// Routes <see cref="AlarmConditionService.TransitionRaised"/> to the matching
/// <see cref="ConditionSink"/> registered during <c>MarkAsAlarmCondition</c>. Translates
/// <see cref="AlarmConditionTransition"/> into the legacy <see cref="AlarmEventArgs"/>
/// shape the existing sink consumes — the sink's switch on <c>AlarmType</c> string
/// ("Active" / "Acknowledged" / "Inactive") is preserved so PR 2.3 doesn't perturb the
/// OPC UA Part 9 state mapping. Stale transitions for an untracked condition are
/// silently dropped.
/// </summary>
private void OnAlarmServiceTransition(object? sender, AlarmConditionTransition t)
{
ConditionSink? sink;
lock (Lock)
{
_conditionSinks.TryGetValue(t.ConditionId, out sink);
}
if (sink is null) return;
var transitionName = t.Transition switch
{
AlarmStateTransition.Active => "Active",
AlarmStateTransition.Acknowledged => "Acknowledged",
AlarmStateTransition.Inactive => "Inactive",
_ => "Unknown",
};
sink.OnTransition(new AlarmEventArgs(
SubscriptionHandle: null!,
SourceNodeId: t.ConditionId,
ConditionId: t.ConditionId,
AlarmType: transitionName,
Message: t.Description ?? t.ConditionId,
Severity: MapPriorityToSeverity(t.Priority),
SourceTimestampUtc: t.AtUtc));
}
/// <summary>
/// Maps the integer priority Galaxy carries on <c>.Priority</c> (typically 1-1000) to
/// the four-bucket <see cref="AlarmSeverity"/> the OPC UA condition sibling consumes.
/// Mirrors the legacy <c>GalaxyProxyDriver.MapSeverity</c> bucketing.
/// </summary>
private static AlarmSeverity MapPriorityToSeverity(int priority) => priority switch
{
<= 250 => AlarmSeverity.Low,
<= 500 => AlarmSeverity.Medium,
<= 800 => AlarmSeverity.High,
_ => AlarmSeverity.Critical,
};
/// <summary>
/// Default <see cref="IAlarmAcknowledger"/> bound to a driver's <see cref="IWritable"/>.
/// Writes the operator comment to the alarm's <c>.AckMsg</c> sub-attribute via the same
/// dispatcher OnWriteValue uses so the resilience pipeline gates the call. Returns
/// false when the driver doesn't implement <see cref="IWritable"/> — alarms whose
/// drivers can't write are tracked but cannot be acknowledged through this path.
/// </summary>
private sealed class DriverWritableAcknowledger(
IWritable? writable, CapabilityInvoker invoker, string driverInstanceId) : IAlarmAcknowledger
{
public async Task<bool> WriteAckMessageAsync(
string ackMsgWriteRef, string comment, CancellationToken cancellationToken)
{
if (writable is null || string.IsNullOrEmpty(ackMsgWriteRef)) return false;
var request = new DriverWriteRequest(
FullReference: ackMsgWriteRef,
Value: comment ?? string.Empty);
try
{
// Ack writes are not idempotent — repeating an ack would re-trigger the
// driver-side acknowledgement state change. False matches the OnWriteValue
// default path for non-Idempotent attributes.
var results = await invoker.ExecuteWriteAsync(
driverInstanceId,
isIdempotent: false,
async ct => await writable.WriteAsync(new[] { request }, ct).ConfigureAwait(false),
cancellationToken).ConfigureAwait(false);
return results.Count > 0 && results[0].StatusCode == 0;
}
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
/// dispatches transitions to a sink that's no longer wired to any OPC UA node.
/// </summary>
protected override void Dispose(bool disposing)
{
if (disposing && _alarmService is not null && _alarmTransitionHandler is not null)
{
_alarmService.TransitionRaised -= _alarmTransitionHandler;
_alarmTransitionHandler = null;
}
base.Dispose(disposing);
}
protected override NodeStateCollection LoadPredefinedNodes(ISystemContext context) => new();
@@ -644,7 +777,22 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
// Without this the Report fires but has no subscribers to deliver to.
_owner.AddRootNotifier(alarm);
return new ConditionSink(_owner, alarm);
var sink = new ConditionSink(_owner, alarm);
// PR 2.3 — when the server-level alarm-condition service is wired, register
// this condition with it so the state machine runs server-side. The sink-map
// entry routes future TransitionRaised events back to this OPC UA node.
// Conditions whose info lacks an InAlarmRef can't be observed without driver
// help — those still rely on the legacy IAlarmSource path until PR 7.2.
if (_owner._alarmService is not null && !string.IsNullOrEmpty(info.InAlarmRef))
{
_owner._conditionSinks[FullReference] = sink;
var acker = new DriverWritableAcknowledger(
_owner._writable, _owner._invoker, _owner._driver.DriverInstanceId);
_owner._alarmService.Track(FullReference, info, acker);
}
return sink;
}
}
@@ -808,29 +956,97 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
internal bool TryGetVariable(string fullRef, out BaseDataVariableState? v)
=> _variablesByFullRef.TryGetValue(fullRef, out v!);
// ===================== HistoryRead service handlers (LMX #1, PR 38) =====================
// ===================== HistoryRead service handlers (LMX #1, PR 38; PR 1.3 routing) =====================
//
// Wires the driver's IHistoryProvider capability (PR 35 added ReadAtTimeAsync / ReadEventsAsync
// alongside the PR 19 ReadRawAsync / ReadProcessedAsync) to the OPC UA HistoryRead service.
// CustomNodeManager2 has four protected per-kind hooks; the base dispatches to the right one
// based on the concrete HistoryReadDetails subtype. Each hook is sync-returning-void — the
// per-driver async calls are bridged via GetAwaiter().GetResult(), matching the pattern
// OnReadValue / OnWriteValue already use in this class so HistoryRead doesn't introduce a
// different sync-over-async convention.
// Wires HistoryRead to the server-level IHistoryRouter (PR 1.2). For each tag:
// (1) the router resolves the longest-matching IHistorianDataSource registration —
// when a server-registered source covers the namespace it wins; (2) when the router
// doesn't match (or no router is configured), we fall back to the driver's own
// IHistoryProvider capability via a thin adapter, preserving the legacy behavior tests
// rely on. PR 1.W will register the legacy adapter inside the router as well, at
// which point this fallback can be deleted.
//
// Per-node routing: every HistoryReadValueId in nodesToRead has a NodeHandle in
// nodesToProcess; the NodeHandle's NodeId.Identifier is the driver-side full reference
// (set during Variable() registration) so we can dispatch straight to IHistoryProvider
// without a second lookup. Nodes without IHistoryProvider backing (drivers that don't
// implement the capability) surface BadHistoryOperationUnsupported per slot and the
// rest of the batch continues — same failure-isolation pattern as OnWriteValue.
//
// Continuation-point handling is pass-through only in this PR: the driver returns null
// from its ContinuationPoint field today so the outer result's ContinuationPoint stays
// empty. Full Session.SaveHistoryContinuationPoint plumbing is a follow-up when a driver
// actually needs paging — the dispatch shape doesn't change, only the result-population.
// Continuation-point handling is pass-through only: the source returns null from its
// ContinuationPoint today so the outer result's ContinuationPoint stays empty. Proper
// Session.SaveHistoryContinuationPoint plumbing is a follow-up when a source actually
// needs paging — the dispatch shape doesn't change, only the result-population.
private IHistoryProvider? History => _driver as IHistoryProvider;
/// <summary>
/// Resolves the historian data source for a given driver full reference. Returns
/// null when neither the router nor the legacy IHistoryProvider path can serve it.
/// </summary>
/// <param name="fullRef">
/// Full reference, or null for driver-root event-history queries (event reads can
/// target a notifier rather than a specific variable). Null fullRef skips router
/// lookup and goes straight to the legacy fallback so today's "all events in the
/// driver namespace" path keeps working.
/// </param>
private IHistorianDataSource? ResolveHistory(string? fullRef)
{
if (fullRef is not null
&& _historyRouter?.Resolve(fullRef) is { } routed)
{
return routed;
}
if (_driver is IHistoryProvider legacy)
{
return _legacyHistoryAdapter ??= new LegacyDriverHistoryAdapter(legacy);
}
return null;
}
/// <summary>
/// Wraps a driver's <see cref="IHistoryProvider"/> as an
/// <see cref="IHistorianDataSource"/> so the four HistoryRead* methods can dispatch
/// through one interface regardless of resolution path. PR 1.W's legacy
/// auto-registration uses the same adapter; PR 7.2 deletes both once
/// IHistoryProvider stops being a driver capability.
/// </summary>
// OTOPCUA0001 (UnwrappedCapabilityCallAnalyzer) flags every direct IHistoryProvider call
// that isn't lexically inside a CapabilityInvoker.ExecuteAsync lambda. The adapter's
// pass-throughs are direct calls — but the four HistoryRead* call sites that own the
// adapter ARE inside ExecuteAsync lambdas, so the wrapping is preserved at runtime.
// Suppress here rather than at every call site.
#pragma warning disable OTOPCUA0001
private sealed class LegacyDriverHistoryAdapter(IHistoryProvider provider) : IHistorianDataSource
{
// HistoryReadResult is unqualified-ambiguous in this file (Core.Abstractions vs.
// Opc.Ua); fully qualify on the adapter signatures so the file's existing var-based
// dispatch sites stay readable.
public Task<Core.Abstractions.HistoryReadResult> ReadRawAsync(
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
CancellationToken cancellationToken)
=> provider.ReadRawAsync(fullReference, startUtc, endUtc, maxValuesPerNode, cancellationToken);
public Task<Core.Abstractions.HistoryReadResult> ReadProcessedAsync(
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
HistoryAggregateType aggregate, CancellationToken cancellationToken)
=> provider.ReadProcessedAsync(fullReference, startUtc, endUtc, interval, aggregate, cancellationToken);
public Task<Core.Abstractions.HistoryReadResult> ReadAtTimeAsync(
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
=> provider.ReadAtTimeAsync(fullReference, timestampsUtc, cancellationToken);
public Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
CancellationToken cancellationToken)
=> provider.ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, cancellationToken);
// Legacy IHistoryProvider has no health surface. Return an "unknown but reachable"
// snapshot so dashboards don't show the data source as broken.
public HistorianHealthSnapshot GetHealthSnapshot()
=> new(0, 0, 0, 0, null, null, null,
ProcessConnectionOpen: true, EventConnectionOpen: true,
ActiveProcessNode: null, ActiveEventNode: null,
Nodes: []);
// Legacy lifecycle is the driver's responsibility — disposing the adapter must
// not dispose the driver out from under DriverNodeManager.
public void Dispose() { }
}
#pragma warning restore OTOPCUA0001
protected override void HistoryReadRawModified(
ServerSystemContext context, ReadRawModifiedDetails details, TimestampsToReturn timestamps,
@@ -838,12 +1054,6 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
IList<ServiceResult> errors, List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> cache)
{
if (History is null)
{
MarkAllUnsupported(nodesToProcess, results, errors);
return;
}
// IsReadModified=true requests a "modifications" history (who changed the data, when
// it was re-written). The driver side has no modifications store — surface that
// explicitly rather than silently returning raw data, which would mislead the client.
@@ -868,6 +1078,13 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
continue;
}
var source = ResolveHistory(fullRef);
if (source is null)
{
WriteUnsupported(results, errors, i);
continue;
}
if (_authzGate is not null && _scopeResolver is not null)
{
var historyScope = _scopeResolver.Resolve(fullRef);
@@ -883,7 +1100,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
var driverResult = _invoker.ExecuteAsync(
DriverCapability.HistoryRead,
ResolveHostFor(fullRef),
async ct => await History.ReadRawAsync(
async ct => await source.ReadRawAsync(
fullRef,
details.StartTime,
details.EndTime,
@@ -912,12 +1129,6 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
IList<ServiceResult> errors, List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> cache)
{
if (History is null)
{
MarkAllUnsupported(nodesToProcess, results, errors);
return;
}
// AggregateType is one NodeId shared across every item in the batch — map once.
var aggregate = MapAggregate(details.AggregateType?.FirstOrDefault());
if (aggregate is null)
@@ -930,10 +1141,6 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
for (var n = 0; n < nodesToProcess.Count; n++)
{
var handle = nodesToProcess[n];
// NodeHandle.Index points back to the slot in the outer results/errors/nodesToRead
// arrays. nodesToProcess is the filtered subset (just the nodes this manager
// claimed), so writing to results[n] lands in the wrong slot when N > 1 and nodes
// are interleaved across multiple node managers.
var i = handle.Index;
var fullRef = ResolveFullRef(handle);
if (fullRef is null)
@@ -942,6 +1149,13 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
continue;
}
var source = ResolveHistory(fullRef);
if (source is null)
{
WriteUnsupported(results, errors, i);
continue;
}
if (_authzGate is not null && _scopeResolver is not null)
{
var historyScope = _scopeResolver.Resolve(fullRef);
@@ -957,7 +1171,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
var driverResult = _invoker.ExecuteAsync(
DriverCapability.HistoryRead,
ResolveHostFor(fullRef),
async ct => await History.ReadProcessedAsync(
async ct => await source.ReadProcessedAsync(
fullRef,
details.StartTime,
details.EndTime,
@@ -987,20 +1201,10 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
IList<ServiceResult> errors, List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> cache)
{
if (History is null)
{
MarkAllUnsupported(nodesToProcess, results, errors);
return;
}
var requestedTimes = (IReadOnlyList<DateTime>)(details.ReqTimes?.ToArray() ?? Array.Empty<DateTime>());
for (var n = 0; n < nodesToProcess.Count; n++)
{
var handle = nodesToProcess[n];
// NodeHandle.Index points back to the slot in the outer results/errors/nodesToRead
// arrays. nodesToProcess is the filtered subset (just the nodes this manager
// claimed), so writing to results[n] lands in the wrong slot when N > 1 and nodes
// are interleaved across multiple node managers.
var i = handle.Index;
var fullRef = ResolveFullRef(handle);
if (fullRef is null)
@@ -1009,6 +1213,13 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
continue;
}
var source = ResolveHistory(fullRef);
if (source is null)
{
WriteUnsupported(results, errors, i);
continue;
}
if (_authzGate is not null && _scopeResolver is not null)
{
var historyScope = _scopeResolver.Resolve(fullRef);
@@ -1024,7 +1235,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
var driverResult = _invoker.ExecuteAsync(
DriverCapability.HistoryRead,
ResolveHostFor(fullRef),
async ct => await History.ReadAtTimeAsync(fullRef, requestedTimes, ct).ConfigureAwait(false),
async ct => await source.ReadAtTimeAsync(fullRef, requestedTimes, ct).ConfigureAwait(false),
CancellationToken.None).AsTask().GetAwaiter().GetResult();
WriteResult(results, errors, i, StatusCodes.Good,
@@ -1048,34 +1259,30 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
IList<ServiceResult> errors, List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> cache)
{
if (History is null)
{
MarkAllUnsupported(nodesToProcess, results, errors);
return;
}
// SourceName filter extraction is deferred — EventFilter SelectClauses + WhereClause
// handling is a dedicated concern (proper per-select-clause Variant population + where
// filter evaluation). This PR treats the event query as "all events in range for the
// node's source" and populates only the standard BaseEventType fields. Richer filter
// handling is a follow-up; clients issuing empty/default filters get the right answer
// today which covers the common alarm-history browse case.
// handling is a dedicated concern. This PR treats the event query as "all events in
// range for the node's source" and populates only the standard BaseEventType fields.
var maxEvents = (int)details.NumValuesPerNode;
if (maxEvents <= 0) maxEvents = 1000;
for (var n = 0; n < nodesToProcess.Count; n++)
{
var handle = nodesToProcess[n];
// NodeHandle.Index points back to the slot in the outer results/errors/nodesToRead
// arrays. nodesToProcess is the filtered subset (just the nodes this manager
// claimed), so writing to results[n] lands in the wrong slot when N > 1 and nodes
// are interleaved across multiple node managers.
var i = handle.Index;
// Event history queries may target a notifier object (e.g. the driver-root folder)
// rather than a specific variable — in that case we pass sourceName=null to mean
// "all sources in the driver's namespace" per the IHistoryProvider contract.
// rather than a specific variable — in that case fullRef is null and we pass
// sourceName=null to the source meaning "all sources in this source's namespace."
var fullRef = ResolveFullRef(handle);
// ResolveHistory tolerates null fullRef — for notifier queries the router is
// skipped and the legacy fallback handles "all sources" reads.
var source = ResolveHistory(fullRef);
if (source is null)
{
WriteUnsupported(results, errors, i);
continue;
}
// fullRef is null for event-history queries that target a notifier (driver root).
// Those are cluster-wide reads + need a different scope shape; skip the gate here
// and let the driver-level authz handle them. Non-null path gets per-node gating.
@@ -1094,7 +1301,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
var driverResult = _invoker.ExecuteAsync(
DriverCapability.HistoryRead,
fullRef is null ? _driver.DriverInstanceId : ResolveHostFor(fullRef),
async ct => await History.ReadEventsAsync(
async ct => await source.ReadEventsAsync(
sourceName: fullRef,
startUtc: details.StartTime,
endUtc: details.EndTime,
@@ -5,6 +5,8 @@ using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
using ZB.MOM.WW.OtOpcUa.Server.Alarms;
using ZB.MOM.WW.OtOpcUa.Server.History;
using ZB.MOM.WW.OtOpcUa.Server.Observability;
using ZB.MOM.WW.OtOpcUa.Server.Security;
@@ -40,6 +42,12 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
private ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _virtualReadable;
private ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _scriptedAlarmReadable;
// PR 1+2.W — server-level singletons. Threaded through to OtOpcUaServer + every
// DriverNodeManager. Default null preserves existing test construction sites that
// don't opt into the new server-side history routing or alarm-condition state machine.
private readonly IHistoryRouter? _historyRouter;
private readonly AlarmConditionService? _alarmConditionService;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<OpcUaApplicationHost> _logger;
private ApplicationInstance? _application;
@@ -57,7 +65,9 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
Func<string, string?>? resilienceConfigLookup = null,
Func<string, ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNamespaceContent?>? equipmentContentLookup = null,
ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? virtualReadable = null,
ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? scriptedAlarmReadable = null)
ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? scriptedAlarmReadable = null,
IHistoryRouter? historyRouter = null,
AlarmConditionService? alarmConditionService = null)
{
_options = options;
_driverHost = driverHost;
@@ -71,6 +81,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
_equipmentContentLookup = equipmentContentLookup;
_virtualReadable = virtualReadable;
_scriptedAlarmReadable = scriptedAlarmReadable;
_historyRouter = historyRouter;
_alarmConditionService = alarmConditionService;
_loggerFactory = loggerFactory;
_logger = logger;
}
@@ -136,7 +148,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
authzGate: _authzGate, scopeResolver: _scopeResolver,
tierLookup: _tierLookup, resilienceConfigLookup: _resilienceConfigLookup,
virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable,
anonymousRoles: _options.AnonymousRoles);
anonymousRoles: _options.AnonymousRoles,
historyRouter: _historyRouter, alarmConditionService: _alarmConditionService);
await _application.Start(_server).ConfigureAwait(false);
_logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}",
@@ -6,6 +6,8 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
using ZB.MOM.WW.OtOpcUa.Server.Alarms;
using ZB.MOM.WW.OtOpcUa.Server.History;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
@@ -34,6 +36,13 @@ public sealed class OtOpcUaServer : StandardServer
private readonly IReadable? _virtualReadable;
private readonly IReadable? _scriptedAlarmReadable;
// PR 1+2.W — server-level singletons shared across every DriverNodeManager.
// Null when the deployment hasn't opted into the new server-side history routing /
// server-side alarm-condition state machine; DriverNodeManager falls back to the
// legacy per-driver IHistoryProvider + IAlarmSource paths in that case.
private readonly IHistoryRouter? _historyRouter;
private readonly AlarmConditionService? _alarmConditionService;
/// <summary>
/// Roles granted to anonymous sessions. When non-empty, <see cref="OnImpersonateUser"/>
/// wraps <c>AnonymousIdentityToken</c> in a <see cref="RoleBasedIdentity"/> carrying
@@ -57,7 +66,9 @@ public sealed class OtOpcUaServer : StandardServer
Func<string, string?>? resilienceConfigLookup = null,
IReadable? virtualReadable = null,
IReadable? scriptedAlarmReadable = null,
IReadOnlyList<string>? anonymousRoles = null)
IReadOnlyList<string>? anonymousRoles = null,
IHistoryRouter? historyRouter = null,
AlarmConditionService? alarmConditionService = null)
{
_driverHost = driverHost;
_authenticator = authenticator;
@@ -69,6 +80,8 @@ public sealed class OtOpcUaServer : StandardServer
_virtualReadable = virtualReadable;
_scriptedAlarmReadable = scriptedAlarmReadable;
_anonymousRoles = anonymousRoles ?? [];
_historyRouter = historyRouter;
_alarmConditionService = alarmConditionService;
_loggerFactory = loggerFactory;
}
@@ -102,7 +115,14 @@ public sealed class OtOpcUaServer : StandardServer
var invoker = new CapabilityInvoker(_pipelineBuilder, driver.DriverInstanceId, () => options, driver.DriverType);
var manager = new DriverNodeManager(server, configuration, driver, invoker, logger,
authzGate: _authzGate, scopeResolver: _scopeResolver,
virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable);
virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable,
historyRouter: _historyRouter, alarmService: _alarmConditionService);
// The router stays empty after PR 1+2.W — DriverNodeManager's internal
// LegacyDriverHistoryAdapter handles every driver that still implements
// IHistoryProvider. PR 3.W will register the Wonderware sidecar as a router
// source; PR 7.2 retires the legacy fallback entirely.
_driverNodeManagers.Add(manager);
}
+13 -1
View File
@@ -17,6 +17,8 @@ using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
using ZB.MOM.WW.OtOpcUa.Driver.S7;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
using ZB.MOM.WW.OtOpcUa.Server;
using ZB.MOM.WW.OtOpcUa.Server.Alarms;
using ZB.MOM.WW.OtOpcUa.Server.History;
using ZB.MOM.WW.OtOpcUa.Server.Hosting;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
@@ -137,6 +139,14 @@ builder.Services.AddScoped<EquipmentNamespaceContentLoader>();
// to ACL enforcement accidentally on upgrade.
builder.Services.AddSingleton<AuthorizationBootstrap>();
// PR 1+2.W — server-level history routing + alarm-condition state machine. Singletons
// shared across every DriverNodeManager. The router stays empty after this PR;
// PR 3.W registers the Wonderware historian sidecar as a router source. The alarm
// service runs the Active/Acknowledged/Inactive state machine for any driver that
// declares alarms via AlarmConditionInfo's sub-attribute refs.
builder.Services.AddSingleton<IHistoryRouter, HistoryRouter>();
builder.Services.AddSingleton<AlarmConditionService>();
builder.Services.AddSingleton<OpcUaApplicationHost>(sp =>
{
var registry = sp.GetRequiredService<DriverEquipmentContentRegistry>();
@@ -146,7 +156,9 @@ builder.Services.AddSingleton<OpcUaApplicationHost>(sp =>
sp.GetRequiredService<IUserAuthenticator>(),
sp.GetRequiredService<ILoggerFactory>(),
sp.GetRequiredService<ILogger<OpcUaApplicationHost>>(),
equipmentContentLookup: registry.Get);
equipmentContentLookup: registry.Get,
historyRouter: sp.GetRequiredService<IHistoryRouter>(),
alarmConditionService: sp.GetRequiredService<AlarmConditionService>());
});
builder.Services.AddHostedService<OpcUaServerService>();
@@ -0,0 +1,146 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
/// <summary>
/// #155 — TagService CRUD round-trip coverage. Mirrors the EquipmentService test shape;
/// uses EF Core InMemory so no SQL Server is required.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TagServiceTests
{
[Fact]
public async Task Create_And_List_Surfaces_The_Tag()
{
using var ctx = NewContext();
var svc = new TagService(ctx);
var created = await svc.CreateAsync(draftId: 1, NewTag("Temp"), TestContext.Current.CancellationToken);
created.TagId.ShouldNotBeNullOrEmpty();
created.GenerationId.ShouldBe(1);
var list = await svc.ListAsync(1, ct: TestContext.Current.CancellationToken);
list.Count.ShouldBe(1);
list[0].Name.ShouldBe("Temp");
}
[Fact]
public async Task List_Filters_By_DriverInstance()
{
using var ctx = NewContext();
var svc = new TagService(ctx);
await svc.CreateAsync(1, NewTag("a", driver: "drv-1"), TestContext.Current.CancellationToken);
await svc.CreateAsync(1, NewTag("b", driver: "drv-2"), TestContext.Current.CancellationToken);
await svc.CreateAsync(1, NewTag("c", driver: "drv-1"), TestContext.Current.CancellationToken);
var d1 = await svc.ListAsync(1, driverInstanceId: "drv-1", ct: TestContext.Current.CancellationToken);
d1.Count.ShouldBe(2);
d1.Select(t => t.Name).ShouldBe(new[] { "a", "c" }, ignoreOrder: true);
}
[Fact]
public async Task Update_Persists_Editable_Fields()
{
using var ctx = NewContext();
var svc = new TagService(ctx);
var t = await svc.CreateAsync(1, NewTag("Original"), TestContext.Current.CancellationToken);
t.Name = "Renamed";
t.DataType = "Float";
t.AccessLevel = TagAccessLevel.ReadWrite;
t.TagConfig = "{\"addressString\":\"40001:F\"}";
await svc.UpdateAsync(t, TestContext.Current.CancellationToken);
var fresh = (await svc.ListAsync(1, ct: TestContext.Current.CancellationToken))[0];
fresh.Name.ShouldBe("Renamed");
fresh.DataType.ShouldBe("Float");
fresh.AccessLevel.ShouldBe(TagAccessLevel.ReadWrite);
fresh.TagConfig.ShouldContain("40001:F");
}
[Fact]
public async Task TagConfig_With_Advanced_Modbus_Fields_RoundTrips_Through_Factory()
{
// #156 — TagsTab serializes advanced fields (deadband / unitId / coalesceProhibited)
// into TagConfig as a structured JSON object alongside addressString. Confirm the
// shape survives a DB round-trip AND that ModbusDriverFactoryExtensions.BuildTag's
// JSON consumer accepts it. If the field names drift between the UI and the factory,
// this test catches it before users do.
using var ctx = NewContext();
var svc = new TagService(ctx);
var advancedConfig = System.Text.Json.JsonSerializer.Serialize(new
{
addressString = "40001:F:CDAB",
deadband = 0.5,
unitId = 7,
coalesceProhibited = true,
});
var t = NewTag("Tank");
t.TagConfig = advancedConfig;
await svc.CreateAsync(1, t, TestContext.Current.CancellationToken);
var fresh = (await svc.ListAsync(1, ct: TestContext.Current.CancellationToken)).Single();
fresh.TagConfig.ShouldContain("addressString");
fresh.TagConfig.ShouldContain("deadband");
fresh.TagConfig.ShouldContain("unitId");
fresh.TagConfig.ShouldContain("coalesceProhibited");
// Build the wrapping driver-config JSON the factory consumes (one tag, the structured
// config above as its TagConfig), then construct a driver from it. If any field name
// doesn't match the DTO, BuildTag throws here.
var driverConfig = System.Text.Json.JsonSerializer.Serialize(new
{
host = "127.0.0.1",
tags = new[]
{
new
{
name = "Tank",
addressString = "40001:F:CDAB",
deadband = 0.5,
unitId = (byte)7,
coalesceProhibited = true,
},
},
});
Should.NotThrow(() => ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusDriverFactoryExtensions.CreateInstance(
"advanced-rt", driverConfig));
}
[Fact]
public async Task Delete_Removes_The_Row()
{
using var ctx = NewContext();
var svc = new TagService(ctx);
var t = await svc.CreateAsync(1, NewTag("Doomed"), TestContext.Current.CancellationToken);
await svc.DeleteAsync(t.TagRowId, TestContext.Current.CancellationToken);
(await svc.ListAsync(1, ct: TestContext.Current.CancellationToken)).ShouldBeEmpty();
}
private static Tag NewTag(string name, string driver = "drv-1") => new()
{
TagId = string.Empty, // CreateAsync auto-assigns
DriverInstanceId = driver,
Name = name,
DataType = "Int32",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{}",
};
private static OtOpcUaConfigDbContext NewContext()
{
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new OtOpcUaConfigDbContext(opts);
}
}
@@ -0,0 +1,100 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.Alarms;
/// <summary>
/// Contract tests for the <see cref="AlarmConditionInfo"/> record extension added in PR 2.1.
/// Five sub-attribute references (InAlarmRef, PriorityRef, DescAttrNameRef, AckedRef,
/// AckMsgWriteRef) carry the driver-side tag references the server-level alarm-condition
/// service uses to subscribe to live alarm-state attributes and route ack writes.
/// </summary>
public sealed class AlarmConditionInfoTests
{
[Fact]
public void LegacyThreeArgConstructor_StillCompiles_AndDefaultsRefsToNull()
{
var info = new AlarmConditionInfo(
SourceName: "Tank.HiHi",
InitialSeverity: AlarmSeverity.High,
InitialDescription: "High-high alarm");
info.SourceName.ShouldBe("Tank.HiHi");
info.InitialSeverity.ShouldBe(AlarmSeverity.High);
info.InitialDescription.ShouldBe("High-high alarm");
info.InAlarmRef.ShouldBeNull();
info.PriorityRef.ShouldBeNull();
info.DescAttrNameRef.ShouldBeNull();
info.AckedRef.ShouldBeNull();
info.AckMsgWriteRef.ShouldBeNull();
}
[Fact]
public void FullConstructor_PopulatesAllFiveSubAttributeRefs()
{
var info = new AlarmConditionInfo(
SourceName: "Tank1.HiAlarm",
InitialSeverity: AlarmSeverity.Medium,
InitialDescription: "Tank level high",
InAlarmRef: "Tank1.HiAlarm.InAlarm",
PriorityRef: "Tank1.HiAlarm.Priority",
DescAttrNameRef: "Tank1.HiAlarm.DescAttrName",
AckedRef: "Tank1.HiAlarm.Acked",
AckMsgWriteRef: "Tank1.HiAlarm.AckMsg");
info.InAlarmRef.ShouldBe("Tank1.HiAlarm.InAlarm");
info.PriorityRef.ShouldBe("Tank1.HiAlarm.Priority");
info.DescAttrNameRef.ShouldBe("Tank1.HiAlarm.DescAttrName");
info.AckedRef.ShouldBe("Tank1.HiAlarm.Acked");
info.AckMsgWriteRef.ShouldBe("Tank1.HiAlarm.AckMsg");
}
[Fact]
public void RecordEquality_ComparesAllEightFields()
{
var a = new AlarmConditionInfo(
"T.Alarm", AlarmSeverity.Low, "desc",
"T.Alarm.InAlarm", "T.Alarm.Priority", "T.Alarm.DescAttrName",
"T.Alarm.Acked", "T.Alarm.AckMsg");
var b = new AlarmConditionInfo(
"T.Alarm", AlarmSeverity.Low, "desc",
"T.Alarm.InAlarm", "T.Alarm.Priority", "T.Alarm.DescAttrName",
"T.Alarm.Acked", "T.Alarm.AckMsg");
a.ShouldBe(b);
}
[Fact]
public void RecordEquality_DistinctWhenAnyRefDiffers()
{
var baseInfo = new AlarmConditionInfo(
"T.Alarm", AlarmSeverity.Low, "desc",
InAlarmRef: "T.Alarm.InAlarm");
var differingAckRef = baseInfo with { AckedRef = "T.Alarm.Acked" };
baseInfo.ShouldNotBe(differingAckRef);
}
[Fact]
public void WithExpression_AllowsPartialUpdates()
{
var legacy = new AlarmConditionInfo("S", AlarmSeverity.Medium, null);
var enriched = legacy with
{
InAlarmRef = "S.InAlarm",
AckedRef = "S.Acked",
AckMsgWriteRef = "S.AckMsg",
};
enriched.SourceName.ShouldBe("S");
enriched.InAlarmRef.ShouldBe("S.InAlarm");
enriched.PriorityRef.ShouldBeNull();
enriched.DescAttrNameRef.ShouldBeNull();
enriched.AckedRef.ShouldBe("S.Acked");
enriched.AckMsgWriteRef.ShouldBe("S.AckMsg");
}
}
@@ -0,0 +1,121 @@
using System.Reflection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.Historian;
/// <summary>
/// Structural contract tests for the historian data-source surface added in PR 1.1.
/// Asserts the type shape — implementations are tested in their own projects.
/// </summary>
public sealed class IHistorianDataSourceContractTests
{
[Fact]
public void Interface_LivesInRootNamespace()
{
typeof(IHistorianDataSource).Namespace
.ShouldBe("ZB.MOM.WW.OtOpcUa.Core.Abstractions");
}
[Fact]
public void Interface_IsPublic()
{
typeof(IHistorianDataSource).IsPublic.ShouldBeTrue();
typeof(IHistorianDataSource).IsInterface.ShouldBeTrue();
}
[Fact]
public void Interface_ExtendsIDisposable()
{
typeof(IDisposable).IsAssignableFrom(typeof(IHistorianDataSource))
.ShouldBeTrue("data sources own backend connections; the server disposes them on shutdown");
}
[Theory]
[InlineData("ReadRawAsync", typeof(Task<HistoryReadResult>))]
[InlineData("ReadProcessedAsync", typeof(Task<HistoryReadResult>))]
[InlineData("ReadAtTimeAsync", typeof(Task<HistoryReadResult>))]
[InlineData("ReadEventsAsync", typeof(Task<HistoricalEventsResult>))]
public void ReadMethods_ReturnExpectedTaskShape(string methodName, Type expectedReturnType)
{
var method = typeof(IHistorianDataSource).GetMethod(methodName);
method.ShouldNotBeNull();
method!.ReturnType.ShouldBe(expectedReturnType);
}
[Fact]
public void GetHealthSnapshot_IsSynchronous()
{
var method = typeof(IHistorianDataSource).GetMethod("GetHealthSnapshot");
method.ShouldNotBeNull();
method!.ReturnType.ShouldBe(typeof(HistorianHealthSnapshot));
}
[Fact]
public void HealthSnapshot_AcceptsEmptyClusterNodeList()
{
var snapshot = new HistorianHealthSnapshot(
TotalQueries: 0,
TotalSuccesses: 0,
TotalFailures: 0,
ConsecutiveFailures: 0,
LastSuccessTime: null,
LastFailureTime: null,
LastError: null,
ProcessConnectionOpen: false,
EventConnectionOpen: false,
ActiveProcessNode: null,
ActiveEventNode: null,
Nodes: Array.Empty<HistorianClusterNodeState>());
snapshot.Nodes.ShouldBeEmpty();
}
[Fact]
public void HealthSnapshot_PreservesClusterNodes()
{
var node = new HistorianClusterNodeState(
Name: "hist-01",
IsHealthy: true,
CooldownUntil: null,
FailureCount: 0,
LastError: null,
LastFailureTime: null);
var snapshot = new HistorianHealthSnapshot(
TotalQueries: 5,
TotalSuccesses: 5,
TotalFailures: 0,
ConsecutiveFailures: 0,
LastSuccessTime: new DateTime(2026, 4, 29, 12, 0, 0, DateTimeKind.Utc),
LastFailureTime: null,
LastError: null,
ProcessConnectionOpen: true,
EventConnectionOpen: true,
ActiveProcessNode: "hist-01",
ActiveEventNode: "hist-01",
Nodes: new[] { node });
snapshot.Nodes.Count.ShouldBe(1);
snapshot.Nodes[0].ShouldBe(node);
}
[Fact]
public void ClusterNodeState_RecordEqualityByValue()
{
var a = new HistorianClusterNodeState("hist-01", true, null, 0, null, null);
var b = new HistorianClusterNodeState("hist-01", true, null, 0, null, null);
a.ShouldBe(b);
}
[Fact]
public void ClusterNodeState_DistinctByAnyField()
{
var healthy = new HistorianClusterNodeState("hist-01", true, null, 0, null, null);
var unhealthy = new HistorianClusterNodeState("hist-01", false, null, 1, "boom", null);
healthy.ShouldNotBe(unhealthy);
}
}
@@ -6,7 +6,7 @@ using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
@@ -8,7 +8,7 @@ using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
@@ -6,7 +6,7 @@ using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
@@ -7,7 +7,7 @@ using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
@@ -2,7 +2,7 @@ using System;
using System.Linq;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
{
@@ -1,6 +1,6 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
@@ -0,0 +1,284 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using MessagePack;
using Serilog;
using Serilog.Core;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
using SidecarHistorianEventDto = ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc.HistorianEventDto;
using BackendHistorianEventDto = ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend.HistorianEventDto;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Ipc;
/// <summary>
/// Round-trip tests for the sidecar pipe contract added in PR 3.3. Each scenario serializes
/// a Request through the wire framing, dispatches via <see cref="HistorianFrameHandler"/>
/// against a fake historian, and asserts the returned Reply round-trips with the expected
/// content. No real named pipe is opened — the framing is exercised over a back-to-back
/// <see cref="MemoryStream"/> pair so tests stay fast and platform-independent.
/// </summary>
public sealed class PipeRoundTripTests
{
private static readonly ILogger Quiet = Logger.None;
private sealed class FakeHistorian : IHistorianDataSource
{
public List<HistorianSample> RawSamples { get; set; } = new();
public List<HistorianAggregateSample> AggregateSamples { get; set; } = new();
public List<HistorianSample> AtTimeSamples { get; set; } = new();
public List<BackendHistorianEventDto> Events { get; set; } = new();
public Exception? ThrowFromRead { get; set; }
public Task<List<HistorianSample>> ReadRawAsync(string tagName, DateTime startTime, DateTime endTime, int maxValues, CancellationToken ct = default)
{
if (ThrowFromRead is not null) throw ThrowFromRead;
return Task.FromResult(RawSamples);
}
public Task<List<HistorianAggregateSample>> ReadAggregateAsync(string tagName, DateTime startTime, DateTime endTime, double intervalMs, string aggregateColumn, CancellationToken ct = default)
=> Task.FromResult(AggregateSamples);
public Task<List<HistorianSample>> ReadAtTimeAsync(string tagName, DateTime[] timestamps, CancellationToken ct = default)
=> Task.FromResult(AtTimeSamples);
public Task<List<BackendHistorianEventDto>> ReadEventsAsync(string? sourceName, DateTime startTime, DateTime endTime, int maxEvents, CancellationToken ct = default)
=> Task.FromResult(Events);
public HistorianHealthSnapshot GetHealthSnapshot() => new();
public void Dispose() { }
}
private sealed class FakeAlarmWriter : IAlarmEventWriter
{
public List<AlarmHistorianEventDto> Received { get; } = new();
public Func<AlarmHistorianEventDto, bool> Decide { get; set; } = _ => true;
public Task<bool[]> WriteAsync(AlarmHistorianEventDto[] events, CancellationToken cancellationToken)
{
Received.AddRange(events);
var result = new bool[events.Length];
for (var i = 0; i < events.Length; i++) result[i] = Decide(events[i]);
return Task.FromResult(result);
}
}
/// <summary>
/// Drives one round trip: serialize <paramref name="request"/>, run the handler,
/// read the reply frame, deserialize it. Returns the reply.
/// </summary>
private static async Task<TReply> RoundTripAsync<TRequest, TReply>(
MessageKind requestKind,
MessageKind expectedReplyKind,
TRequest request,
IFrameHandler handler)
{
// Build the request body the same way FrameWriter would, but feed it directly into
// the handler's Handle method (the pipe server has already read the kind + body
// before handing them to the handler).
var requestBody = MessagePackSerializer.Serialize(request);
using var stream = new MemoryStream();
using var writer = new FrameWriter(stream, leaveOpen: true);
await handler.HandleAsync(requestKind, requestBody, writer, CancellationToken.None);
stream.Position = 0;
using var reader = new FrameReader(stream, leaveOpen: true);
var frame = await reader.ReadFrameAsync(CancellationToken.None);
frame.ShouldNotBeNull();
frame!.Value.Kind.ShouldBe(expectedReplyKind);
return MessagePackSerializer.Deserialize<TReply>(frame.Value.Body);
}
[Fact]
public async Task ReadRaw_RoundTripsSamples()
{
var historian = new FakeHistorian();
historian.RawSamples.Add(new HistorianSample { Value = 42.0, Quality = 192, TimestampUtc = new DateTime(2026, 4, 29, 12, 0, 0, DateTimeKind.Utc) });
historian.RawSamples.Add(new HistorianSample { Value = 43.5, Quality = 192, TimestampUtc = new DateTime(2026, 4, 29, 12, 0, 1, DateTimeKind.Utc) });
var handler = new HistorianFrameHandler(historian, Quiet);
var reply = await RoundTripAsync<ReadRawRequest, ReadRawReply>(
MessageKind.ReadRawRequest, MessageKind.ReadRawReply,
new ReadRawRequest
{
TagName = "Tank.Level",
StartUtcTicks = new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc).Ticks,
EndUtcTicks = new DateTime(2026, 4, 30, 0, 0, 0, DateTimeKind.Utc).Ticks,
MaxValues = 100,
CorrelationId = "corr-1",
}, handler);
reply.Success.ShouldBeTrue();
reply.Error.ShouldBeNull();
reply.CorrelationId.ShouldBe("corr-1");
reply.Samples.Length.ShouldBe(2);
reply.Samples[0].Quality.ShouldBe((byte)192);
reply.Samples[0].TimestampUtcTicks.ShouldBe(new DateTime(2026, 4, 29, 12, 0, 0, DateTimeKind.Utc).Ticks);
reply.Samples[0].ValueBytes.ShouldNotBeNull();
MessagePackSerializer.Deserialize<double>(reply.Samples[0].ValueBytes!).ShouldBe(42.0);
}
[Fact]
public async Task ReadRaw_FailureSurfacesAsErrorReply()
{
var historian = new FakeHistorian { ThrowFromRead = new InvalidOperationException("boom") };
var handler = new HistorianFrameHandler(historian, Quiet);
var reply = await RoundTripAsync<ReadRawRequest, ReadRawReply>(
MessageKind.ReadRawRequest, MessageKind.ReadRawReply,
new ReadRawRequest { TagName = "Tag", CorrelationId = "fail-1" }, handler);
reply.Success.ShouldBeFalse();
reply.Error.ShouldBe("boom");
reply.CorrelationId.ShouldBe("fail-1");
reply.Samples.ShouldBeEmpty();
}
[Fact]
public async Task ReadProcessed_RoundTripsBuckets()
{
var historian = new FakeHistorian();
historian.AggregateSamples.Add(new HistorianAggregateSample { Value = 50.0, TimestampUtc = new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc) });
historian.AggregateSamples.Add(new HistorianAggregateSample { Value = null, TimestampUtc = new DateTime(2026, 4, 29, 0, 1, 0, DateTimeKind.Utc) });
var handler = new HistorianFrameHandler(historian, Quiet);
var reply = await RoundTripAsync<ReadProcessedRequest, ReadProcessedReply>(
MessageKind.ReadProcessedRequest, MessageKind.ReadProcessedReply,
new ReadProcessedRequest { TagName = "Tank.Level", IntervalMs = 60000, AggregateColumn = "Average", CorrelationId = "p-1" },
handler);
reply.Success.ShouldBeTrue();
reply.Buckets.Length.ShouldBe(2);
reply.Buckets[0].Value.ShouldBe(50.0);
reply.Buckets[1].Value.ShouldBeNull(); // unavailable bucket
}
[Fact]
public async Task ReadAtTime_RoundTripsSamples()
{
var historian = new FakeHistorian();
historian.AtTimeSamples.Add(new HistorianSample { Value = 7, Quality = 192, TimestampUtc = new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc) });
var handler = new HistorianFrameHandler(historian, Quiet);
var reply = await RoundTripAsync<ReadAtTimeRequest, ReadAtTimeReply>(
MessageKind.ReadAtTimeRequest, MessageKind.ReadAtTimeReply,
new ReadAtTimeRequest
{
TagName = "Tank.Level",
TimestampsUtcTicks = new[] { new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc).Ticks },
CorrelationId = "t-1",
}, handler);
reply.Success.ShouldBeTrue();
reply.Samples.Length.ShouldBe(1);
}
[Fact]
public async Task ReadEvents_RoundTripsEvents()
{
var historian = new FakeHistorian();
var eid = Guid.Parse("11111111-1111-1111-1111-111111111111");
historian.Events.Add(new BackendHistorianEventDto
{
Id = eid,
Source = "Tank.HiHi",
EventTime = new DateTime(2026, 4, 29, 1, 0, 0, DateTimeKind.Utc),
ReceivedTime = new DateTime(2026, 4, 29, 1, 0, 1, DateTimeKind.Utc),
DisplayText = "Level high-high",
Severity = 800,
});
var handler = new HistorianFrameHandler(historian, Quiet);
var reply = await RoundTripAsync<ReadEventsRequest, ReadEventsReply>(
MessageKind.ReadEventsRequest, MessageKind.ReadEventsReply,
new ReadEventsRequest { SourceName = "Tank.HiHi", MaxEvents = 100, CorrelationId = "e-1" },
handler);
reply.Success.ShouldBeTrue();
reply.Events.Length.ShouldBe(1);
reply.Events[0].EventId.ShouldBe(eid.ToString());
reply.Events[0].Source.ShouldBe("Tank.HiHi");
reply.Events[0].DisplayText.ShouldBe("Level high-high");
reply.Events[0].Severity.ShouldBe((ushort)800);
}
[Fact]
public async Task WriteAlarmEvents_RoutesToWriter_AndReturnsPerEventStatus()
{
var historian = new FakeHistorian();
var alarmWriter = new FakeAlarmWriter
{
// Simulate "second event fails" to verify per-event status flows through.
Decide = e => e.EventId != "ev-2",
};
var handler = new HistorianFrameHandler(historian, Quiet, alarmWriter);
var request = new WriteAlarmEventsRequest
{
CorrelationId = "wa-1",
Events = new[]
{
new AlarmHistorianEventDto { EventId = "ev-1", SourceName = "Tank.HiHi", AlarmType = "Active", Severity = 800, EventTimeUtcTicks = DateTime.UtcNow.Ticks },
new AlarmHistorianEventDto { EventId = "ev-2", SourceName = "Tank.HiHi", AlarmType = "Acknowledged", Severity = 800, EventTimeUtcTicks = DateTime.UtcNow.Ticks },
},
};
var reply = await RoundTripAsync<WriteAlarmEventsRequest, WriteAlarmEventsReply>(
MessageKind.WriteAlarmEventsRequest, MessageKind.WriteAlarmEventsReply,
request, handler);
reply.Success.ShouldBeTrue();
reply.PerEventOk.Length.ShouldBe(2);
reply.PerEventOk[0].ShouldBeTrue();
reply.PerEventOk[1].ShouldBeFalse();
alarmWriter.Received.Count.ShouldBe(2);
}
[Fact]
public async Task WriteAlarmEvents_FailsCleanly_WhenNoWriterConfigured()
{
var historian = new FakeHistorian();
var handler = new HistorianFrameHandler(historian, Quiet, alarmWriter: null);
var reply = await RoundTripAsync<WriteAlarmEventsRequest, WriteAlarmEventsReply>(
MessageKind.WriteAlarmEventsRequest, MessageKind.WriteAlarmEventsReply,
new WriteAlarmEventsRequest
{
CorrelationId = "wa-2",
Events = new[] { new AlarmHistorianEventDto { EventId = "ev-1" } },
}, handler);
reply.Success.ShouldBeFalse();
reply.Error.ShouldNotBeNull();
reply.PerEventOk.Length.ShouldBe(1);
reply.PerEventOk[0].ShouldBeFalse();
}
[Fact]
public async Task FrameReader_FrameWriter_RoundTripPreservesKindAndBody()
{
// Pure framing-layer test — confirms the length-prefix + kind-byte + body protocol
// is the same on both sides without any handler in the loop.
using var stream = new MemoryStream();
using var writer = new FrameWriter(stream, leaveOpen: true);
var hello = new Hello { ProtocolMajor = 1, PeerName = "test-peer", SharedSecret = "secret" };
await writer.WriteAsync(MessageKind.Hello, hello, CancellationToken.None);
stream.Position = 0;
using var reader = new FrameReader(stream, leaveOpen: true);
var frame = await reader.ReadFrameAsync(CancellationToken.None);
frame.ShouldNotBeNull();
frame!.Value.Kind.ShouldBe(MessageKind.Hello);
var decoded = MessagePackSerializer.Deserialize<Hello>(frame.Value.Body);
decoded.PeerName.ShouldBe("test-peer");
decoded.SharedSecret.ShouldBe("secret");
}
}
@@ -0,0 +1,21 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests;
/// <summary>
/// Smoke test confirming the sidecar project links and the test project resolves a
/// ProjectReference to it. Real behavioural tests arrive in PR 3.2 (backend lift) and
/// PR 3.3 (pipe server). For PR 3.1 we just verify the assembly identity is what the
/// csproj declares.
/// </summary>
public class ProgramSmokeTests
{
[Fact]
public void Program_Assembly_HasExpectedName()
{
typeof(Program).Assembly.GetName().Name
.ShouldBe("OtOpcUa.Driver.Historian.Wonderware");
}
}
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<PlatformTarget>x86</PlatformTarget>
<Prefer32Bit>true</Prefer32Bit>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit" Version="2.9.2"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.csproj"/>
</ItemGroup>
</Project>
@@ -156,6 +156,38 @@ public sealed class ModbusCoalescingAutoRecoveryTests
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task GetAutoProhibitedRanges_Surfaces_Operator_Visible_Snapshot()
{
// #152 — diagnostic accessor returns the live prohibition map as a snapshot of public
// ModbusAutoProhibition records. Consumers (Admin UI, dashboards) project this list
// into whatever shape they need.
var fake = new ProtectedHoleTransport { ProtectedAddress = 102 };
var t100 = new ModbusTagDefinition("T100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var t102 = new ModbusTagDefinition("T102", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
var t104 = new ModbusTagDefinition("T104", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", UnitId = 7, Tags = [t100, t102, t104], MaxReadGap = 5,
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
// Pre-failure: nothing prohibited.
drv.GetAutoProhibitedRanges().ShouldBeEmpty();
await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None);
var snapshot = drv.GetAutoProhibitedRanges();
snapshot.Count.ShouldBe(1);
snapshot[0].UnitId.ShouldBe((byte)7);
snapshot[0].Region.ShouldBe(ModbusRegion.HoldingRegisters);
snapshot[0].StartAddress.ShouldBe((ushort)100);
snapshot[0].EndAddress.ShouldBe((ushort)104);
snapshot[0].BisectionPending.ShouldBeTrue("multi-register prohibition starts split-pending");
snapshot[0].LastProbedUtc.ShouldBeGreaterThan(DateTime.UtcNow.AddMinutes(-1));
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Tags_Outside_Prohibited_Range_Still_Coalesce()
{
@@ -0,0 +1,171 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// #150 — bisection-style range narrowing for coalescing prohibitions. After a coalesced
/// read fails, the re-probe loop bisects the prohibited range over multiple ticks until
/// it pinpoints the actual protected register(s). Healthy halves get cleared as the
/// bisection narrows.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusCoalescingBisectionTests
{
/// <summary>
/// Programmable transport like the one in ModbusCoalescingAutoRecoveryTests but local
/// to keep this test file standalone — having the protection model live next to the
/// bisection assertions makes the test intent easier to read.
/// </summary>
private sealed class ProtectedHoleTransport : IModbusTransport
{
public ushort ProtectedAddress { get; set; } = ushort.MaxValue;
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
if (pdu[0] is 0x03 or 0x04 && ProtectedAddress >= addr && ProtectedAddress < addr + qty)
return Task.FromException<byte[]>(new ModbusException(pdu[0], 0x02, "IllegalDataAddress"));
switch (pdu[0])
{
case 0x03: case 0x04:
{
var resp = new byte[2 + qty * 2];
resp[0] = pdu[0]; resp[1] = (byte)(qty * 2);
return Task.FromResult(resp);
}
default: return Task.FromResult(new byte[] { pdu[0], 0, 0 });
}
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
[Fact]
public async Task Bisection_Narrows_Multi_Register_Prohibition_Per_Reprobe()
{
var fake = new ProtectedHoleTransport { ProtectedAddress = 105 };
// 11 tags 100..110 with MaxReadGap=10 → coalesce into one block 100..110. The protected
// register is in the middle (105). After the first failure the planner records the
// full 100..110 range as split-pending. Each subsequent re-probe bisects until the
// prohibition is pinned at register 105.
var tags = Enumerable.Range(100, 11)
.Select(i => new ModbusTagDefinition($"T{i}", ModbusRegion.HoldingRegisters, (ushort)i, ModbusDataType.Int16))
.ToArray();
var opts = new ModbusDriverOptions { Host = "f", Tags = tags, MaxReadGap = 10,
AutoProhibitReprobeInterval = TimeSpan.FromMilliseconds(100),
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(tags.Select(t => t.Name).ToArray(), CancellationToken.None);
// Initial prohibition: full 100..110 range, split-pending.
drv.AutoProhibitedRangeCount.ShouldBe(1);
// Re-probe pass 1: bisect 100..110 → mid=105 → left=100..105 (fails because 105 is
// protected), right=106..110 (succeeds). Result: prohibition collapses to 100..105.
await drv.RunReprobeOnceForTestAsync(CancellationToken.None);
drv.AutoProhibitedRangeCount.ShouldBe(1, "after pass 1 the prohibition narrows but doesn't disappear");
// Re-probe pass 2: bisect 100..105 → mid=102 → left=100..102 (succeeds), right=103..105 (fails).
// Result: prohibition collapses to 103..105.
await drv.RunReprobeOnceForTestAsync(CancellationToken.None);
// Re-probe pass 3: bisect 103..105 → mid=104 → left=103..104 (succeeds), right=105..105 (fails).
// Result: prohibition collapses to 105..105 (single register, no longer split-pending).
await drv.RunReprobeOnceForTestAsync(CancellationToken.None);
drv.AutoProhibitedRangeCount.ShouldBe(1, "single-register prohibition stays after bisection terminates");
// Re-probe pass 4: 105..105 is single-register; straight-retry path. Still fails;
// prohibition stays.
await drv.RunReprobeOnceForTestAsync(CancellationToken.None);
drv.AutoProhibitedRangeCount.ShouldBe(1);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Bisection_Clears_When_Both_Halves_Are_Healthy()
{
// Transient failure scenario: range failed once, but by the next re-probe the PLC has
// unlocked it. Bisection of (100..110) returns success on both halves → entry removed
// entirely.
var fake = new ProtectedHoleTransport { ProtectedAddress = 105 };
var tags = Enumerable.Range(100, 11)
.Select(i => new ModbusTagDefinition($"T{i}", ModbusRegion.HoldingRegisters, (ushort)i, ModbusDataType.Int16))
.ToArray();
var opts = new ModbusDriverOptions { Host = "f", Tags = tags, MaxReadGap = 10,
AutoProhibitReprobeInterval = TimeSpan.FromMilliseconds(100),
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(tags.Select(t => t.Name).ToArray(), CancellationToken.None);
drv.AutoProhibitedRangeCount.ShouldBe(1);
// Operator unlocks the protected register before the re-probe runs.
fake.ProtectedAddress = ushort.MaxValue;
await drv.RunReprobeOnceForTestAsync(CancellationToken.None);
drv.AutoProhibitedRangeCount.ShouldBe(0, "both bisected halves succeed → parent prohibition cleared entirely");
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Bisection_Splits_Into_Two_When_Both_Halves_Still_Fail()
{
// Two protected registers in the same coalesced range: 102 and 108. After bisection,
// both halves of the original (100..110) range still contain a protected address
// (left=100..105 contains 102, right=106..110 contains 108). The prohibition replaces
// the parent with TWO smaller split-pending entries.
var fake = new ProtectedHoleTransport();
// Build a more elaborate transport that protects two addresses.
var twoHole = new TwoHoleTransport { ProtectedAddresses = { 102, 108 } };
var tags = Enumerable.Range(100, 11)
.Select(i => new ModbusTagDefinition($"T{i}", ModbusRegion.HoldingRegisters, (ushort)i, ModbusDataType.Int16))
.ToArray();
var opts = new ModbusDriverOptions { Host = "f", Tags = tags, MaxReadGap = 10,
AutoProhibitReprobeInterval = TimeSpan.FromMilliseconds(100),
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => twoHole);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(tags.Select(t => t.Name).ToArray(), CancellationToken.None);
drv.AutoProhibitedRangeCount.ShouldBe(1);
// Re-probe: bisect 100..110 at mid=105 → left=100..105 (contains 102, fails),
// right=106..110 (contains 108, fails). Result: TWO entries in place of the parent.
await drv.RunReprobeOnceForTestAsync(CancellationToken.None);
drv.AutoProhibitedRangeCount.ShouldBe(2, "both halves still fail → prohibition splits into two");
await drv.ShutdownAsync(CancellationToken.None);
}
private sealed class TwoHoleTransport : IModbusTransport
{
public readonly HashSet<ushort> ProtectedAddresses = new();
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
if (pdu[0] is 0x03 or 0x04)
for (var i = 0; i < qty; i++)
if (ProtectedAddresses.Contains((ushort)(addr + i)))
return Task.FromException<byte[]>(new ModbusException(pdu[0], 0x02, "IllegalDataAddress"));
switch (pdu[0])
{
case 0x03: case 0x04:
{
var resp = new byte[2 + qty * 2];
resp[0] = pdu[0]; resp[1] = (byte)(qty * 2);
return Task.FromResult(resp);
}
default: return Task.FromResult(new byte[] { pdu[0], 0, 0 });
}
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -0,0 +1,99 @@
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// #153 — confirm ModbusDriver emits structured warnings on first-fire of an
/// auto-prohibition and informational logs on re-probe clearance. The logger plumbing
/// extends through ModbusDriverFactoryExtensions.Register so production server-bootstrap
/// paths get the logger automatically; here we exercise the constructor injection
/// directly.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusLoggerInjectionTests
{
private sealed class CapturingLogger : ILogger<ModbusDriver>
{
public readonly List<(LogLevel Level, string Message)> Entries = new();
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
=> Entries.Add((logLevel, formatter(state, exception)));
private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); public void Dispose() { } }
}
private sealed class ProtectedHoleTransport : IModbusTransport
{
public ushort ProtectedAddress { get; set; } = 102;
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
if (pdu[0] is 0x03 && ProtectedAddress >= addr && ProtectedAddress < addr + qty)
return Task.FromException<byte[]>(new ModbusException(0x03, 0x02, "IllegalDataAddress"));
var resp = new byte[2 + qty * 2];
resp[0] = pdu[0]; resp[1] = (byte)(qty * 2);
return Task.FromResult(resp);
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
[Fact]
public async Task First_Failure_Emits_Single_Warning_Subsequent_Refire_Stays_Quiet()
{
var fake = new ProtectedHoleTransport();
var logger = new CapturingLogger();
var t100 = new ModbusTagDefinition("T100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var t102 = new ModbusTagDefinition("T102", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
var t104 = new ModbusTagDefinition("T104", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", Tags = [t100, t102, t104], MaxReadGap = 5,
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "drv-logged", _ => fake, logger: logger);
await drv.InitializeAsync("{}", CancellationToken.None);
// Scan 1 — coalesced read fails. Expect exactly one warning.
await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None);
var warnings = logger.Entries.Where(e => e.Level == LogLevel.Warning).ToList();
warnings.Count.ShouldBe(1);
warnings[0].Message.ShouldContain("drv-logged");
warnings[0].Message.ShouldContain("Start=100");
warnings[0].Message.ShouldContain("End=104");
// Scan 2 — same coalesced range still fails. Re-fire is suppressed (planner sees
// the prohibition and skips the merge; even if it didn't, the de-dupe in
// RecordAutoProhibition would suppress).
await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None);
logger.Entries.Count(e => e.Level == LogLevel.Warning).ShouldBe(1, "re-fire of same range stays silent");
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Reprobe_Clearing_Prohibition_Emits_Information_Log()
{
var fake = new ProtectedHoleTransport();
var logger = new CapturingLogger();
var t100 = new ModbusTagDefinition("T100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var t102 = new ModbusTagDefinition("T102", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
var t104 = new ModbusTagDefinition("T104", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", Tags = [t100, t102, t104], MaxReadGap = 5,
AutoProhibitReprobeInterval = TimeSpan.FromHours(1), // long interval — we drive it manually
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "drv-logged", _ => fake, logger: logger);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None);
// Operator unlocks the protected register; re-probe should clear + log.
fake.ProtectedAddress = ushort.MaxValue;
await drv.RunReprobeOnceForTestAsync(CancellationToken.None);
var infoLogs = logger.Entries.Where(e => e.Level == LogLevel.Information && e.Message.Contains("cleared")).ToList();
infoLogs.Count.ShouldBeGreaterThanOrEqualTo(1, "re-probe success must emit a 'cleared' info log");
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -0,0 +1,331 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Server.Alarms;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Alarms;
/// <summary>
/// Server-level alarm-condition state-machine tests added in PR 2.2. Ports the live
/// transition cases from <c>GalaxyAlarmTrackerTests</c> against the new
/// driver-agnostic <see cref="AlarmConditionService"/>: sub-attribute references come
/// from <see cref="AlarmConditionInfo"/>, value changes flow as
/// <see cref="DataValueSnapshot"/> instead of MX-specific <c>Vtq</c>, and the ack
/// write path is decoupled into <see cref="IAlarmAcknowledger"/>.
/// </summary>
public sealed class AlarmConditionServiceTests
{
private const string ConditionId = "TankFarm.Tank1.Level.HiHi";
private const string InAlarmRef = "TankFarm.Tank1.Level.HiHi.InAlarm";
private const string PriorityRef = "TankFarm.Tank1.Level.HiHi.Priority";
private const string DescRef = "TankFarm.Tank1.Level.HiHi.DescAttrName";
private const string AckedRef = "TankFarm.Tank1.Level.HiHi.Acked";
private const string AckMsgWriteRef = "TankFarm.Tank1.Level.HiHi.AckMsg";
private static AlarmConditionInfo Info(
string? inAlarm = InAlarmRef, string? priority = PriorityRef,
string? desc = DescRef, string? acked = AckedRef, string? ackMsg = AckMsgWriteRef)
=> new(
SourceName: ConditionId,
InitialSeverity: AlarmSeverity.Medium,
InitialDescription: null,
InAlarmRef: inAlarm,
PriorityRef: priority,
DescAttrNameRef: desc,
AckedRef: acked,
AckMsgWriteRef: ackMsg);
private static DataValueSnapshot Bool(bool v) =>
new(v, StatusCode: 0, SourceTimestampUtc: DateTime.UtcNow, ServerTimestampUtc: DateTime.UtcNow);
private static DataValueSnapshot Int(int v) =>
new(v, 0, DateTime.UtcNow, DateTime.UtcNow);
private static DataValueSnapshot Str(string v) =>
new(v, 0, DateTime.UtcNow, DateTime.UtcNow);
private sealed class FakeAcker : IAlarmAcknowledger
{
public readonly ConcurrentQueue<(string Ref, string Comment)> Writes = new();
public bool ReturnValue { get; set; } = true;
public Task<bool> WriteAckMessageAsync(string ackMsgWriteRef, string comment, CancellationToken cancellationToken)
{
Writes.Enqueue((ackMsgWriteRef, comment));
return Task.FromResult(ReturnValue);
}
}
[Fact]
public void Track_AddsCondition_AndExposesSubscribedReferences()
{
using var svc = new AlarmConditionService();
svc.Track(ConditionId, Info());
svc.TrackedCount.ShouldBe(1);
var refs = svc.GetSubscribedReferences();
refs.ShouldContain(InAlarmRef);
refs.ShouldContain(PriorityRef);
refs.ShouldContain(DescRef);
refs.ShouldContain(AckedRef);
refs.Count.ShouldBe(4);
}
[Fact]
public void Track_IsIdempotentOnRepeatCall()
{
using var svc = new AlarmConditionService();
svc.Track(ConditionId, Info());
svc.Track(ConditionId, Info());
svc.TrackedCount.ShouldBe(1);
}
[Fact]
public void Track_OmitsNullSubAttributeRefs()
{
using var svc = new AlarmConditionService();
// Driver may not expose every sub-attribute (e.g. no .Acked observable).
svc.Track(ConditionId, Info(priority: null, desc: null, acked: null));
svc.GetSubscribedReferences().ShouldBe(new[] { InAlarmRef });
}
[Fact]
public void InAlarmFalseToTrue_FiresActiveTransition()
{
using var svc = new AlarmConditionService();
var transitions = new ConcurrentQueue<AlarmConditionTransition>();
svc.TransitionRaised += (_, t) => transitions.Enqueue(t);
svc.Track(ConditionId, Info());
svc.OnValueChanged(PriorityRef, Int(500));
svc.OnValueChanged(DescRef, Str("Tank level high-high"));
svc.OnValueChanged(InAlarmRef, Bool(true));
transitions.Count.ShouldBe(1);
transitions.TryDequeue(out var t).ShouldBeTrue();
t!.Transition.ShouldBe(AlarmStateTransition.Active);
t.Priority.ShouldBe(500);
t.Description.ShouldBe("Tank level high-high");
t.ConditionId.ShouldBe(ConditionId);
}
[Fact]
public void InAlarmTrueToFalse_FiresInactiveTransition()
{
using var svc = new AlarmConditionService();
var transitions = new ConcurrentQueue<AlarmConditionTransition>();
svc.TransitionRaised += (_, t) => transitions.Enqueue(t);
svc.Track(ConditionId, Info());
svc.OnValueChanged(InAlarmRef, Bool(true));
svc.OnValueChanged(InAlarmRef, Bool(false));
transitions.Count.ShouldBe(2);
transitions.TryDequeue(out _);
transitions.TryDequeue(out var t).ShouldBeTrue();
t!.Transition.ShouldBe(AlarmStateTransition.Inactive);
}
[Fact]
public void AckedFalseToTrue_FiresAcknowledged_WhileActive()
{
using var svc = new AlarmConditionService();
var transitions = new ConcurrentQueue<AlarmConditionTransition>();
svc.TransitionRaised += (_, t) => transitions.Enqueue(t);
svc.Track(ConditionId, Info());
svc.OnValueChanged(InAlarmRef, Bool(true)); // Active, resets Acked → false
svc.OnValueChanged(AckedRef, Bool(true)); // Acknowledged
transitions.Count.ShouldBe(2);
transitions.TryDequeue(out _);
transitions.TryDequeue(out var t).ShouldBeTrue();
t!.Transition.ShouldBe(AlarmStateTransition.Acknowledged);
}
[Fact]
public void AckedTransitionWhileInactive_DoesNotFire()
{
using var svc = new AlarmConditionService();
var transitions = new ConcurrentQueue<AlarmConditionTransition>();
svc.TransitionRaised += (_, t) => transitions.Enqueue(t);
svc.Track(ConditionId, Info());
// Initial Acked=true on subscribe (alarm at rest, pre-ack'd) — must not fire.
svc.OnValueChanged(AckedRef, Bool(true));
transitions.ShouldBeEmpty();
}
[Fact]
public void RepeatedActiveTransitions_ResetAckedFlag()
{
using var svc = new AlarmConditionService();
var transitions = new ConcurrentQueue<AlarmConditionTransition>();
svc.TransitionRaised += (_, t) => transitions.Enqueue(t);
svc.Track(ConditionId, Info());
// Cycle 1: active → ack → inactive → active again
svc.OnValueChanged(InAlarmRef, Bool(true));
svc.OnValueChanged(AckedRef, Bool(true));
svc.OnValueChanged(InAlarmRef, Bool(false));
svc.OnValueChanged(InAlarmRef, Bool(true)); // re-arms — Acked must reset to false
svc.OnValueChanged(AckedRef, Bool(true)); // produces a fresh Acknowledged
// Active, Acknowledged, Inactive, Active, Acknowledged
transitions.Count.ShouldBe(5);
var ordered = transitions.Select(t => t.Transition).ToArray();
ordered.ShouldBe(new[]
{
AlarmStateTransition.Active,
AlarmStateTransition.Acknowledged,
AlarmStateTransition.Inactive,
AlarmStateTransition.Active,
AlarmStateTransition.Acknowledged,
});
}
[Fact]
public async Task AcknowledgeAsync_RoutesToAckerWithAckMsgRef()
{
using var svc = new AlarmConditionService();
var acker = new FakeAcker();
svc.Track(ConditionId, Info(), acker);
var ok = await svc.AcknowledgeAsync(ConditionId, "operator-1: cleared", CancellationToken.None);
ok.ShouldBeTrue();
acker.Writes.Count.ShouldBe(1);
acker.Writes.TryDequeue(out var w).ShouldBeTrue();
w.Ref.ShouldBe(AckMsgWriteRef);
w.Comment.ShouldBe("operator-1: cleared");
}
[Fact]
public async Task AcknowledgeAsync_ReturnsFalse_WhenConditionUntracked()
{
using var svc = new AlarmConditionService();
var acker = new FakeAcker();
svc.Track("OtherCondition", Info(), acker);
var ok = await svc.AcknowledgeAsync(ConditionId, "comment");
ok.ShouldBeFalse();
acker.Writes.ShouldBeEmpty();
}
[Fact]
public async Task AcknowledgeAsync_ReturnsFalse_WhenNoAckerRegistered()
{
using var svc = new AlarmConditionService();
svc.Track(ConditionId, Info(), acker: null);
var ok = await svc.AcknowledgeAsync(ConditionId, "comment");
ok.ShouldBeFalse();
}
[Fact]
public async Task AcknowledgeAsync_ReturnsFalse_WhenAckMsgRefMissing()
{
using var svc = new AlarmConditionService();
var acker = new FakeAcker();
svc.Track(ConditionId, Info(ackMsg: null), acker);
var ok = await svc.AcknowledgeAsync(ConditionId, "comment");
ok.ShouldBeFalse();
acker.Writes.ShouldBeEmpty();
}
[Fact]
public void Snapshot_ReportsLatestFields()
{
using var svc = new AlarmConditionService();
svc.Track(ConditionId, Info());
svc.OnValueChanged(InAlarmRef, Bool(true));
svc.OnValueChanged(PriorityRef, Int(900));
svc.OnValueChanged(DescRef, Str("MyAlarm"));
svc.OnValueChanged(AckedRef, Bool(true));
var snap = svc.Snapshot();
snap.Count.ShouldBe(1);
snap[0].ConditionId.ShouldBe(ConditionId);
snap[0].InAlarm.ShouldBeTrue();
snap[0].Acked.ShouldBeTrue();
snap[0].Priority.ShouldBe(900);
snap[0].Description.ShouldBe("MyAlarm");
}
[Fact]
public void OnValueChanged_ForUnknownReference_IsSilentlyIgnored()
{
using var svc = new AlarmConditionService();
var transitions = new ConcurrentQueue<AlarmConditionTransition>();
svc.TransitionRaised += (_, t) => transitions.Enqueue(t);
svc.OnValueChanged("Some.Random.Tag.InAlarm", Bool(true));
transitions.ShouldBeEmpty();
}
[Fact]
public void Untrack_RemovesConditionAndReleasesReferences()
{
using var svc = new AlarmConditionService();
svc.Track(ConditionId, Info());
svc.Untrack(ConditionId);
svc.TrackedCount.ShouldBe(0);
svc.GetSubscribedReferences().ShouldBeEmpty();
}
[Fact]
public void Untrack_NonexistentConditionIsNoOp()
{
using var svc = new AlarmConditionService();
svc.Track(ConditionId, Info());
Should.NotThrow(() => svc.Untrack("does-not-exist"));
svc.TrackedCount.ShouldBe(1);
}
[Fact]
public void Track_ThrowsAfterDisposal()
{
var svc = new AlarmConditionService();
svc.Dispose();
Should.Throw<ObjectDisposedException>(() => svc.Track(ConditionId, Info()));
}
[Fact]
public void OnValueChanged_AfterDisposal_IsSilentlyDropped()
{
var svc = new AlarmConditionService();
svc.Track(ConditionId, Info());
svc.Dispose();
// Stale callbacks during disposal must not throw.
Should.NotThrow(() => svc.OnValueChanged(InAlarmRef, Bool(true)));
}
[Fact]
public void PriorityCoercion_AcceptsCommonNumericTypes()
{
using var svc = new AlarmConditionService();
svc.Track(ConditionId, Info());
svc.OnValueChanged(PriorityRef, new DataValueSnapshot((short)123, 0, null, DateTime.UtcNow));
svc.OnValueChanged(InAlarmRef, Bool(true));
var snap = svc.Snapshot()[0];
snap.Priority.ShouldBe(123);
}
}
@@ -157,6 +157,76 @@ public sealed class HealthEndpointsHostTests : IAsyncLifetime
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.NotFound);
}
// ===== #154 — driver-diagnostics endpoint =====
[Fact]
public async Task Diagnostics_ReturnsModbusAutoProhibitions_ForLiveDriver()
{
// Bring up a Modbus driver with a programmable transport that protects register 102,
// record one prohibition, then hit /diagnostics/drivers/{id}/modbus/auto-prohibited.
var fake = new ModbusDriverDiagnosticsTransport { ProtectedAddress = 102 };
var t1 = new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusTagDefinition(
"T1", ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusRegion.HoldingRegisters, 100, ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusDataType.Int16);
var t2 = new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusTagDefinition(
"T2", ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusRegion.HoldingRegisters, 102, ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusDataType.Int16);
var opts = new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusDriverOptions
{
Host = "f", Tags = [t1, t2], MaxReadGap = 5,
Probe = new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusProbeOptions { Enabled = false },
};
var driver = new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusDriver(opts, "diag-mb", _ => fake);
await _driverHost.RegisterAsync(driver, "{}", CancellationToken.None);
await driver.ReadAsync(["T1", "T2"], CancellationToken.None);
Start();
var response = await _client.GetAsync("/diagnostics/drivers/diag-mb/modbus/auto-prohibited");
response.IsSuccessStatusCode.ShouldBeTrue();
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
body.GetProperty("driverInstanceId").GetString().ShouldBe("diag-mb");
body.GetProperty("count").GetInt32().ShouldBe(1);
var first = body.GetProperty("ranges")[0];
first.GetProperty("startAddress").GetInt32().ShouldBe(100);
first.GetProperty("endAddress").GetInt32().ShouldBe(102);
first.GetProperty("region").GetString().ShouldBe("HoldingRegisters");
first.GetProperty("bisectionPending").GetBoolean().ShouldBeTrue();
}
[Fact]
public async Task Diagnostics_404_When_Driver_Not_Found()
{
Start();
var response = await _client.GetAsync("/diagnostics/drivers/no-such/modbus/auto-prohibited");
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.NotFound);
}
[Fact]
public async Task Diagnostics_400_When_Driver_Is_Wrong_Type()
{
await _driverHost.RegisterAsync(new StubDriver("not-modbus", DriverState.Healthy), "{}", CancellationToken.None);
Start();
var response = await _client.GetAsync("/diagnostics/drivers/not-modbus/modbus/auto-prohibited");
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.BadRequest);
}
private sealed class ModbusDriverDiagnosticsTransport : ZB.MOM.WW.OtOpcUa.Driver.Modbus.IModbusTransport
{
public ushort ProtectedAddress { get; set; } = 102;
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
if (pdu[0] is 0x03 && ProtectedAddress >= addr && ProtectedAddress < addr + qty)
return Task.FromException<byte[]>(new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusException(0x03, 0x02, "IllegalDataAddress"));
var resp = new byte[2 + qty * 2];
resp[0] = pdu[0]; resp[1] = (byte)(qty * 2);
return Task.FromResult(resp);
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
private sealed class StubDriver : IDriver
{
private readonly DriverState _state;
@@ -0,0 +1,169 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Server.History;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.History;
/// <summary>
/// Tests for <see cref="HistoryRouter"/> registration + resolution semantics added
/// in PR 1.2. The router is the only seam between OPC UA HistoryRead service calls
/// and registered <see cref="IHistorianDataSource"/> implementations, so the
/// resolution rules (case-insensitive prefix, longest-match wins, no source =>
/// null) need explicit coverage.
/// </summary>
public sealed class HistoryRouterTests
{
[Fact]
public void Resolve_ReturnsNull_WhenNoSourceRegistered()
{
using var router = new HistoryRouter();
router.Resolve("anything").ShouldBeNull();
}
[Fact]
public void Resolve_ReturnsRegisteredSource_WhenPrefixMatches()
{
using var router = new HistoryRouter();
var source = new FakeSource("galaxy");
router.Register("galaxy", source);
router.Resolve("galaxy.TankFarm.Tank1.Level").ShouldBe(source);
}
[Fact]
public void Resolve_ReturnsNull_WhenPrefixDoesNotMatch()
{
using var router = new HistoryRouter();
router.Register("galaxy", new FakeSource("galaxy"));
router.Resolve("modbus.MyDevice.Tag1").ShouldBeNull();
}
[Fact]
public void Resolve_LongestPrefixWins_WhenMultipleRegistered()
{
using var router = new HistoryRouter();
var generic = new FakeSource("generic");
var specific = new FakeSource("specific");
router.Register("galaxy", generic);
router.Register("galaxy.HighRate", specific);
router.Resolve("galaxy.HighRate.Sensor1").ShouldBe(specific);
router.Resolve("galaxy.LowRate.Sensor2").ShouldBe(generic);
}
[Fact]
public void Resolve_IsCaseInsensitive_OnPrefixMatch()
{
using var router = new HistoryRouter();
var source = new FakeSource("galaxy");
router.Register("Galaxy", source);
router.Resolve("galaxy.foo").ShouldBe(source);
router.Resolve("GALAXY.foo").ShouldBe(source);
}
[Fact]
public void Register_Throws_WhenPrefixAlreadyRegistered()
{
using var router = new HistoryRouter();
router.Register("galaxy", new FakeSource("first"));
Should.Throw<InvalidOperationException>(
() => router.Register("galaxy", new FakeSource("second")));
}
[Fact]
public void Dispose_DisposesAllRegisteredSources()
{
var router = new HistoryRouter();
var a = new FakeSource("a");
var b = new FakeSource("b");
router.Register("ns_a", a);
router.Register("ns_b", b);
router.Dispose();
a.IsDisposed.ShouldBeTrue();
b.IsDisposed.ShouldBeTrue();
}
[Fact]
public void Dispose_SwallowsExceptionsFromMisbehavingSource()
{
var router = new HistoryRouter();
var throwing = new ThrowingFakeSource();
var clean = new FakeSource("clean");
router.Register("bad", throwing);
router.Register("good", clean);
// Even when one source's Dispose throws, the router must finish disposing the
// remaining sources (server shutdown invariant).
Should.NotThrow(() => router.Dispose());
clean.IsDisposed.ShouldBeTrue();
}
[Fact]
public void Resolve_Throws_AfterDisposal()
{
var router = new HistoryRouter();
router.Dispose();
Should.Throw<ObjectDisposedException>(() => router.Resolve("anything"));
}
[Fact]
public void Register_Throws_AfterDisposal()
{
var router = new HistoryRouter();
router.Dispose();
Should.Throw<ObjectDisposedException>(
() => router.Register("ns", new FakeSource("x")));
}
private sealed class FakeSource(string name) : IHistorianDataSource
{
public string Name { get; } = name;
public bool IsDisposed { get; private set; }
public void Dispose() => IsDisposed = true;
public Task<HistoryReadResult> ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken cancellationToken)
=> throw new NotImplementedException();
public Task<HistoryReadResult> ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, HistoryAggregateType aggregate, CancellationToken cancellationToken)
=> throw new NotImplementedException();
public Task<HistoryReadResult> ReadAtTimeAsync(string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
=> throw new NotImplementedException();
public Task<HistoricalEventsResult> ReadEventsAsync(string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken)
=> throw new NotImplementedException();
public HistorianHealthSnapshot GetHealthSnapshot()
=> new(0, 0, 0, 0, null, null, null, false, false, null, null, []);
}
private sealed class ThrowingFakeSource : IHistorianDataSource
{
public void Dispose() => throw new InvalidOperationException("boom");
public Task<HistoryReadResult> ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken cancellationToken)
=> throw new NotImplementedException();
public Task<HistoryReadResult> ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, HistoryAggregateType aggregate, CancellationToken cancellationToken)
=> throw new NotImplementedException();
public Task<HistoryReadResult> ReadAtTimeAsync(string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
=> throw new NotImplementedException();
public Task<HistoricalEventsResult> ReadEventsAsync(string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken)
=> throw new NotImplementedException();
public HistorianHealthSnapshot GetHealthSnapshot()
=> new(0, 0, 0, 0, null, null, null, false, false, null, null, []);
}
}