docs(historian-gateway): relocate OtOpcUa↔HistorianGateway integration plan + design
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
This commit is contained in:
@@ -0,0 +1,220 @@
|
|||||||
|
# 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 1–1000.
|
||||||
|
|
||||||
|
**`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.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"planPath": "docs/plans/2026-06-26-otopcua-historian-gateway-integration.md",
|
||||||
|
"tasks": [
|
||||||
|
{ "id": 0, "subject": "Task 1: Consume gateway packages + scaffold Gateway driver project", "status": "pending", "blockedBy": [] },
|
||||||
|
{ "id": 1, "subject": "Task 2: HistoryAggregateType->RetrievalMode mapper (matrix-guarded)", "status": "pending", "blockedBy": [0] },
|
||||||
|
{ "id": 2, "subject": "Task 3: DriverDataType->HistorianDataType mapper + write-gap fallbacks (matrix-guarded)", "status": "pending", "blockedBy": [0] },
|
||||||
|
{ "id": 3, "subject": "Task 4: HistorianSample/Aggregate->DataValueSnapshot + quality mapper", "status": "pending", "blockedBy": [0] },
|
||||||
|
{ "id": 4, "subject": "Task 5: HistorianEvent->HistoricalEvent mapper (+ severity)", "status": "pending", "blockedBy": [0] },
|
||||||
|
{ "id": 5, "subject": "Task 6: AlarmHistorianEvent->HistorianEvent mapper (SendEvent)", "status": "pending", "blockedBy": [0] },
|
||||||
|
{ "id": 6, "subject": "Task 7: GatewayHistorianDataSource read paths (raw/processed/at-time)", "status": "pending", "blockedBy": [1, 3] },
|
||||||
|
{ "id": 7, "subject": "Task 8: GetHealthSnapshot via Probe/GetConnectionStatus", "status": "pending", "blockedBy": [6] },
|
||||||
|
{ "id": 8, "subject": "Task 9: Reshape ServerHistorianOptions to gateway form", "status": "pending", "blockedBy": [0] },
|
||||||
|
{ "id": 9, "subject": "Task 10: Swap AddServerHistorian factory in Program.cs (READ CUTOVER)", "status": "pending", "blockedBy": [6, 8] },
|
||||||
|
{ "id": 10, "subject": "Task 11: ReadEventsAsync alarm-history on the data source", "status": "pending", "blockedBy": [6, 4] },
|
||||||
|
{ "id": 11, "subject": "Task 12: GatewayAlarmHistorianWriter (SendEvent + outcome mapping)", "status": "pending", "blockedBy": [9, 5] },
|
||||||
|
{ "id": 12, "subject": "Task 13: Swap AddAlarmHistorian factory in Program.cs", "status": "pending", "blockedBy": [11] },
|
||||||
|
{ "id": 13, "subject": "Task 14: IHistorianProvisioning + GatewayTagProvisioner (EnsureTags)", "status": "pending", "blockedBy": [9, 2] },
|
||||||
|
{ "id": 14, "subject": "Task 15: Hook provisioning into AddressSpaceApplier.Apply()", "status": "pending", "blockedBy": [13] },
|
||||||
|
{ "id": 15, "subject": "Task 16: FasterLog historization outbox store", "status": "pending", "blockedBy": [9] },
|
||||||
|
{ "id": 16, "subject": "Task 17: ContinuousHistorizationRecorder actor", "status": "pending", "blockedBy": [15, 9] },
|
||||||
|
{ "id": 17, "subject": "Task 18: Wire recorder into DI + hosted lifecycle", "status": "pending", "blockedBy": [16] },
|
||||||
|
{ "id": 18, "subject": "Task 19: Retire Wonderware historian projects", "status": "pending", "blockedBy": [9, 12, 17, 19] },
|
||||||
|
{ "id": 19, "subject": "Task 20: Env-gated live validation vs wonder-sql-vd03", "status": "pending", "blockedBy": [9, 10, 12, 17] },
|
||||||
|
{ "id": 20, "subject": "Task 21: Documentation (CLAUDE.md, appsettings, README)", "status": "pending", "blockedBy": [18] }
|
||||||
|
],
|
||||||
|
"lastUpdated": "2026-06-26"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user