Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f7a4ac769 | |||
| bc7ec746c5 | |||
| 9365beb966 | |||
| ef22a61c39 | |||
| 012c42a846 | |||
| ec57df1009 | |||
| 802366c2c6 | |||
| 8004394892 | |||
| b8df230eb8 | |||
| f823c81c96 |
@@ -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
@@ -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
@@ -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 1–3.
|
||||
|
||||
### 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 1–3 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
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| < threshold.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">UnitId override
|
||||
<span class="text-muted">(0–255, 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";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+4
-28
@@ -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>
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -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
-1
@@ -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
|
||||
+1
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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 & 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;
|
||||
}
|
||||
}
|
||||
+68
@@ -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 > 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 > 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,80 +505,134 @@ 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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
try { await RunReprobeOnceForTestAsync(ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <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);
|
||||
try
|
||||
{
|
||||
_ = await ReadRegisterBlockAsync(transport, p.Unit, fc, p.Start, qty, ct).ConfigureAwait(false);
|
||||
lock (_autoProhibitedLock) _autoProhibited.Remove(p);
|
||||
}
|
||||
catch
|
||||
{
|
||||
lock (_autoProhibitedLock)
|
||||
if (_autoProhibited.ContainsKey(p))
|
||||
_autoProhibited[p] = DateTime.UtcNow;
|
||||
}
|
||||
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, 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.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>
|
||||
/// #143 block-read coalescing planner. Groups eligible tags by (UnitId, Region), sorts
|
||||
/// by start address, and merges adjacent / near-adjacent (gap ≤ MaxReadGap) into single
|
||||
|
||||
@@ -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,7 +86,14 @@ public sealed class HealthEndpointsHost : IAsyncDisposable
|
||||
await WriteReadyzAsync(ctx).ConfigureAwait(false);
|
||||
break;
|
||||
default:
|
||||
ctx.Response.StatusCode = 404;
|
||||
// #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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
+121
@@ -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;
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -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");
|
||||
}
|
||||
}
|
||||
+28
@@ -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, []);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user