Files
lmxopcua/docs/plans/2026-06-26-otopcua-historian-backend-design.md
T

221 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# OtOpcUa ↔ HistorianGateway Historian Backend — Design
**Date:** 2026-06-26
**Status:** Design approved; implementation in two plans (see end).
**Repos:** `~/Desktop/HistorianGateway` (gateway + client lib), `~/Desktop/OtOpcUa` (OPC UA server consumer)
---
## 1. Goal
Make **HistorianGateway** the historian read/write backend for the **OtOpcUa** OPC UA server,
serving two distinct use cases:
1. **Read of historic values for mxaccessgw-served (Galaxy) tags & alarms.** Galaxy tags are
already historized by AVEVA's own IOServer/AppServer pipeline; OtOpcUa serves their history
to OPC UA `HistoryRead` clients by reading them back through the gateway.
2. **Full read/write historian backend for non-mxaccessgw tags & alarms** (Modbus / S7 / AB /
TwinCAT / FOCAS / scripted-alarm sources). These are *not* historized by AVEVA, so OtOpcUa
**records** their live value changes and alarm events into the historian through the gateway,
then reads them back through the same path.
The vehicle, per decision, is a **dedicated .NET gRPC client library** for the gateway —
`ZB.MOM.WW.HistorianGateway.Client` — built "similar to the mxaccessgw client"
(`ZB.MOM.WW.MxGateway.Client`), which OtOpcUa consumes as a Gitea-feed package.
## 2. Locked decisions (from brainstorming)
| Decision | Choice |
|---|---|
| Write model for non-Galaxy tags | **Continuous historization** — OtOpcUa records live value changes automatically |
| Relation to existing Wonderware TCP-sidecar backend | **Replace it** — gateway becomes the sole historian backend; retire the Wonderware driver projects |
| Alarm/event history | **In scope for v1**`HistoryReadEvents` from the gateway + route OtOpcUa alarm events to `SendEvent` |
| Client library location & consumption | **In the gateway repo (`clients/dotnet/`), published to the Gitea feed**; OtOpcUa references Contracts + Client as packages (mirrors how it already consumes `ZB.MOM.WW.GalaxyRepository @ 0.2.0`) |
| Continuous-historization durability | **Mirror the gateway's StoreForward design** — an OtOpcUa-side crash-safe FasterLog append-only outbox (so values buffer durably when the *gateway itself* is unreachable) |
| Deliverable | **One design doc, two implementation plans** (gateway-client plan; OtOpcUa-integration plan) |
## 3. Why this is tractable — the seams already exist
OtOpcUa's historian integration was designed for pluggable backends. The gateway slots into
seams that are already in place; only two genuinely-new pieces are required (the recorder and tag
provisioning).
| OtOpcUa seam | File | Role for us |
|---|---|---|
| `IHistorianDataSource` | `src/Core/…Core.Abstractions/Historian/IHistorianDataSource.cs` | Read surface (`ReadRaw/ReadProcessed/ReadAtTime/ReadEvents` + `GetHealthSnapshot`); wired into the NodeManager's `HistoryReadRawModified`/`HistoryReadEvents` overrides |
| `IAlarmHistorianWriter` | `src/Core/…Core.AlarmHistorian/IAlarmHistorianSink.cs` | Alarm-event write surface (`WriteBatchAsync(batch)`); already fronted by `SqliteStoreAndForwardSink` |
| `AddServerHistorian(cfg, factory)` | `src/Server/…Runtime/ServiceCollectionExtensions.cs` | Generic over `Func<ServerHistorianOptions, IServiceProvider, IHistorianDataSource>` — swap the factory, zero change to Runtime/OpcUaServer |
| `AddAlarmHistorian(cfg, writerFactory)` | same | Generic over the `IAlarmHistorianWriter` factory — swap to the gateway writer |
| `DependencyMuxActor` | `src/Server/…Runtime/VirtualTags/DependencyMuxActor.cs` | Value-change fan-out (`RegisterInterest` + `AttributeValuePublished`) — the tap point for continuous historization |
| `AddressSpaceApplier.Apply()` | `src/Server/…OpcUaServer/AddressSpaceApplier.cs` | Per-tag iteration over `plan.AddedEquipmentTags.Where(IsHistorized)` — the hook for `EnsureTags` provisioning |
**Currently** these seams are filled by `WonderwareHistorianClient` (a single class implementing
both `IHistorianDataSource` and `IAlarmHistorianWriter` over a bespoke **TCP FrameChannel** to an
ArchestrA-SDK sidecar) — exactly the COM-bound approach HistorianGateway was built to replace.
## 4. Gateway gRPC surface vs. OtOpcUa needs
The gateway's `historian_gateway.v1` contract already covers the surface. Mapping:
| OtOpcUa need | Gateway RPC | Notes |
|---|---|---|
| `ReadRawAsync` | `HistorianRead.ReadRaw` (stream) | direct |
| `ReadProcessedAsync` | `HistorianRead.ReadAggregate` (stream) | `HistoryAggregateType``RetrievalMode` mapping (§6) |
| `ReadAtTimeAsync` | `HistorianRead.ReadAtTime` (unary) | direct |
| `ReadEventsAsync` | `HistorianRead.ReadEvents` (stream) | needs gateway `RuntimeDb:EventReadsEnabled=true` (C2 SQL path) + source-name filter (gateway gap §5) |
| continuous value write | `HistorianWrite.WriteLiveValues` | SQL live path; needs gateway `RuntimeDb:Enabled=true`; numeric/analog only (§7) |
| alarm event write | `HistorianWrite.SendEvent` | maps `AlarmHistorianEvent``HistorianEvent` |
| tag provisioning | `HistorianTags.EnsureTags` | `DriverDataType``HistorianDataType` mapping (§6) |
| health/diagnostics | `HistorianStatus.Probe` / `GetConnectionStatus` | feeds `GetHealthSnapshot()` |
Galaxy hierarchy browse (`GalaxyRepository` service) is **not** needed here — OtOpcUa already gets
Galaxy hierarchy via mxaccessgw's `GalaxyRepositoryClient`.
## 5. What gets added to HistorianGateway
1. **`ZB.MOM.WW.HistorianGateway.Client`** (NEW, `clients/dotnet/`). Clones the `MxGatewayClient`
pattern: `HistorianGatewayClient.Create(options)` owning a `GrpcChannel` over a
`SocketsHttpHandler` (TLS, connect timeout), Polly resilience pipeline (retry transient codes
only), `histgw_<id>_<secret>` bearer key attached in the `authorization` metadata header,
typed exception hierarchy, and wrappers for all five services (unary → `Task<T>`, streaming →
`IAsyncEnumerable<T>`). Packable NuGet, references the Contracts project.
2. **Make `ZB.MOM.WW.HistorianGateway.Contracts` packable + publish to the Gitea feed** (it has no
packaging props today). Mirrors `ZB.MOM.WW.MxGateway.Contracts @ 0.1.x`. This is what lets the
Client and OtOpcUa consume generated `historian_gateway.v1` types as a package.
3. **SQL `ReadEvents` source-name filter** (small enhancement, coordinated with the in-flight
`feat/sql-readevents` branch). The SQL event-read path is currently time-range-only
(per-property filter → `Unimplemented`); add `Source_Object` filtering so OtOpcUa's
`ReadEventsAsync(sourceName, …)` is server-filtered rather than full-window + client-side filter.
4. **Optional smoke CLI** (`…Client.Cli`) mirroring `mxgw` cli — manual live checks.
5. **Deployment/config prerequisites** (no code): the gateway OtOpcUa points at must run with
`RuntimeDb:Enabled=true` (WriteLiveValues) **and** `RuntimeDb:EventReadsEnabled=true` (alarm
reads). Provision an API key carrying `historian:read`, `historian:write`,
`historian:tags:write`.
## 6. Mapping tables (single source of truth for the mappers)
**`HistoryAggregateType` (OPC UA) → `RetrievalMode` (gateway).** Mirror the existing
`WonderwareHistorianClient.ReadProcessedAsync` mapping as the authoritative reference; expected:
| `HistoryAggregateType` | `RetrievalMode` |
|---|---|
| `Average` | `TimeWeightedAverage` |
| `Minimum` | `MinimumWithTime` |
| `Maximum` | `MaximumWithTime` |
| `Total` | `Integral` |
| `Count` | `Counter` *(verify against Wonderware client; may have no exact native mode)* |
**`DriverDataType` (OtOpcUa) → `HistorianDataType` (gateway), for `EnsureTags`/`WriteLiveValues`.**
Constrained by which writes are server-proven (CLAUDE.md: write-captured = Int1/2/4/8, UInt4/8,
Float, Double):
| `DriverDataType` | `HistorianDataType` | Write status |
|---|---|---|
| `Boolean` | `Int1` | proven |
| `Int16` | `Int2` | proven |
| `Int32` | `Int4` | proven |
| `Int64` | `Int8` | proven |
| `UInt16` | `UInt4` *(fallback — UInt2 write is deferred upstream)* | proven via fallback |
| `UInt32` | `UInt4` | proven |
| `UInt64` | `UInt8` | proven |
| `Float32` | `Float` | proven |
| `Float64` | `Double` | proven |
| `String` | `SingleByteString` | **deferred — gated upstream; not historized in v1** |
| `DateTime` | `FileTime` | **deferred — not on the analog write path** |
| `Reference` | (string) | **deferred** |
**`HistorianSample``DataValueSnapshot`:** `Value` ← numeric/string value; `StatusCode`
quality translated to OPC UA status (reuse Wonderware client's quality translation);
`SourceTimestampUtc` ← sample timestamp; `ServerTimestampUtc` ← received/processing time.
**`HistorianEvent``HistoricalEvent`:** `EventId` ← id; `SourceName` ← source_name;
`EventTimeUtc` ← event_time; `ReceivedTimeUtc` ← received_time; `Message` ← properties (rendered);
`Severity` ← properties (Priority/Severity) mapped to OPC UA 11000.
**`AlarmHistorianEvent``HistorianEvent` (SendEvent):** `source_name``EquipmentPath`;
`event_time``TimestampUtc`; `type``AlarmTypeName`; rich fields (`AlarmName`, `EventKind`,
`Severity`, `User`, `Comment`, `Message`) carried in the `properties` map.
## 7. New OtOpcUa components
```
NEW src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/
GatewayHistorianDataSource : IHistorianDataSource — read adapter over the client
GatewayAlarmHistorianWriter : IAlarmHistorianWriter — SendEvent; behind existing SqliteStoreAndForwardSink
GatewayTagProvisioner : IHistorianProvisioning — EnsureTags (NEW interface)
Mappers — the §6 tables, with matrix-guard unit tests
NEW ContinuousHistorizationRecorder (Runtime actor + FasterLog outbox)
- registers RegisterInterest with DependencyMuxActor for historized non-Galaxy tag refs
- appends each AttributeValuePublished to a crash-safe FasterLog outbox (PerEntry/Periodic
commit, mirroring the gateway's FasterLogOutboxStore)
- background drainer batches → client.WriteLiveValues; commits/truncates on ack; backoff on
failure; outbox-full → drop-oldest + metric
HOOK AddressSpaceApplier.Apply() — for plan.AddedEquipmentTags.Where(IsHistorized) →
provisioner.EnsureTags (non-blocking; failures logged + counted, never block publish)
SWAP Program.cs — AddServerHistorian + AddAlarmHistorian factories construct the Gateway-backed impls
CONF ServerHistorian options reshaped to gateway form (Endpoint / ApiKey / Tls); drop SharedSecret
RETIRE src/Drivers/*Wonderware* (3 src + 2 test projects) after live validation
```
## 8. Data flow
**Use case 1 — Galaxy tag history read:**
`UA HistoryRead → OtOpcUaNodeManager.HistoryReadRawModified → GatewayHistorianDataSource.ReadRaw →
client.ReadRaw → gateway → AVEVA historian (already historized by AVEVA IOServer)`.
**Use case 2 — non-Galaxy tag record + read:**
- *Provision (deploy):* `AddressSpaceApplier.Apply → GatewayTagProvisioner.EnsureTags → client.EnsureTags → gateway`.
- *Record (runtime):* `driver value change → DriverInstanceActor.AttributeValuePublished → DependencyMuxActor → ContinuousHistorizationRecorder → FasterLog outbox → drainer → client.WriteLiveValues → gateway (SQL live path)`.
- *Read back:* same path as use case 1.
- *Alarms:* `ScriptedAlarmEngine → HistorianAdapterActor → SqliteStoreAndForwardSink → GatewayAlarmHistorianWriter.WriteBatchAsync → client.SendEvent`; alarm-history read via `GatewayHistorianDataSource.ReadEvents → client.ReadEvents`.
## 9. Error handling
- **Client:** `RpcException` → typed hierarchy (`HistorianGatewayException`,
`…AuthenticationException`/`Unauthenticated`, `…AuthorizationException`/`PermissionDenied`,
`…UnavailableException`/`Unavailable`). Polly retries transient codes only.
- **Read adapter:** quality → OPC UA `StatusCode` inside the data source; empty windows are not
faults; backend errors surface as `Bad` snapshots, never crash a `HistoryRead`.
- **Provisioning:** non-blocking — log + count failures; address-space publish always proceeds.
- **Recorder:** append-to-outbox is the durable boundary; drain failures back off; outbox-full →
drop-oldest + metric; health via `GetHealthSnapshot` + meter.
## 10. Testing
- **Client lib:** fake-transport unit tests (clone mxaccessgw `FakeGatewayTransport`) — auth-header
attach, retry, streaming, exception mapping; golden proto round-trips; smoke CLI.
- **OtOpcUa adapter:** unit tests with a fake `IHistorianGatewayClient` for every §6 mapper
(matrix-guard so a new enum member fails the build); recorder tested against fake outbox + fake
client (batch/drain/outage/drop); provisioning hook over a synthetic plan.
- **Live (env-gated, skips without VPN):** reuse the `wonder-sql-vd03` fixture — Galaxy-tag read
round-trip; write→read round-trip on a `HistGW.LiveTest.*` tag; alarm `SendEvent``ReadEvents`.
## 11. Verify-live risks (settle during implementation, not now)
1. **Galaxy-tag → historian-tag identity** — does OtOpcUa's `historianTagname`
(`tag_name.Attribute`) match the AVEVA historian tag name? Confirm against `wonder-sql-vd03` early.
2. **UInt16 / String / DateTime write gaps** — continuous historization is numeric-analog only in
v1; documented mappings/fallbacks in §6, not silent drops.
3. **Alarm-history reads depend on `feat/sql-readevents`** landing + gateway
`RuntimeDb:EventReadsEnabled=true`; the source-name filter (§5.3) is the one coordinated gateway
enhancement.
4. **`WriteLiveValues` requires gateway `RuntimeDb:Enabled=true`** and an `EnsureTags`-provisioned tag.
5. **`received_time` UTC semantics** on the SQL event/value paths (local vs UTC; `EventTimeUTCOffsetMins`)
— inherit whatever the `feat/sql-readevents` work establishes.
## 12. Implementation plans
- **Plan 1 — Gateway client (`docs/plans/2026-06-26-historian-gateway-client.md`):** Contracts
packable + publish → client options/channel/auth → Polly + exception mapping → per-service
wrappers → fake-transport tests → CLI → SQL-ReadEvents source filter (coordinated) → live smoke.
- **Plan 2 — OtOpcUa integration (`docs/plans/2026-06-26-otopcua-historian-gateway-integration.md`):**
new Gateway driver project → mappers (matrix-guard) → read adapter + `AddServerHistorian` swap →
alarm writer adapter + `AddAlarmHistorian` swap → `ReadEvents` alarm-history → continuous-
historization recorder (FasterLog outbox) → `EnsureTags` provisioning hook → retire Wonderware →
live-validate. (Authored here; relocates into `~/Desktop/OtOpcUa/docs/plans/` on its own branch
when that phase starts, to avoid entangling OtOpcUa's current in-flight working tree.)
Plan 1 is a prerequisite for Plan 2 (OtOpcUa consumes the published Client package). Within Plan 2,
the read path (phases through `AddServerHistorian` swap) is independently shippable and validates
use case 1 before any write code lands.