Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
15 KiB
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:
- 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
HistoryReadclients by reading them back through the gateway. - 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
ZB.MOM.WW.HistorianGateway.Client(NEW,clients/dotnet/). Clones theMxGatewayClientpattern:HistorianGatewayClient.Create(options)owning aGrpcChannelover aSocketsHttpHandler(TLS, connect timeout), Polly resilience pipeline (retry transient codes only),histgw_<id>_<secret>bearer key attached in theauthorizationmetadata header, typed exception hierarchy, and wrappers for all five services (unary →Task<T>, streaming →IAsyncEnumerable<T>). Packable NuGet, references the Contracts project.- Make
ZB.MOM.WW.HistorianGateway.Contractspackable + publish to the Gitea feed (it has no packaging props today). MirrorsZB.MOM.WW.MxGateway.Contracts @ 0.1.x. This is what lets the Client and OtOpcUa consume generatedhistorian_gateway.v1types as a package. - SQL
ReadEventssource-name filter (small enhancement, coordinated with the in-flightfeat/sql-readeventsbranch). The SQL event-read path is currently time-range-only (per-property filter →Unimplemented); addSource_Objectfiltering so OtOpcUa'sReadEventsAsync(sourceName, …)is server-filtered rather than full-window + client-side filter. - Optional smoke CLI (
…Client.Cli) mirroringmxgwcli — manual live checks. - Deployment/config prerequisites (no code): the gateway OtOpcUa points at must run with
RuntimeDb:Enabled=true(WriteLiveValues) andRuntimeDb:EventReadsEnabled=true(alarm reads). Provision an API key carryinghistorian: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 viaGatewayHistorianDataSource.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
StatusCodeinside the data source; empty windows are not faults; backend errors surface asBadsnapshots, never crash aHistoryRead. - 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
IHistorianGatewayClientfor 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-vd03fixture — Galaxy-tag read round-trip; write→read round-trip on aHistGW.LiveTest.*tag; alarmSendEvent→ReadEvents.
11. Verify-live risks (settle during implementation, not now)
- Galaxy-tag → historian-tag identity — does OtOpcUa's
historianTagname(tag_name.Attribute) match the AVEVA historian tag name? Confirm againstwonder-sql-vd03early. - UInt16 / String / DateTime write gaps — continuous historization is numeric-analog only in v1; documented mappings/fallbacks in §6, not silent drops.
- Alarm-history reads depend on
feat/sql-readeventslanding + gatewayRuntimeDb:EventReadsEnabled=true; the source-name filter (§5.3) is the one coordinated gateway enhancement. WriteLiveValuesrequires gatewayRuntimeDb:Enabled=trueand anEnsureTags-provisioned tag.received_timeUTC semantics on the SQL event/value paths (local vs UTC;EventTimeUTCOffsetMins) — inherit whatever thefeat/sql-readeventswork 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 +AddServerHistorianswap → alarm writer adapter +AddAlarmHistorianswap →ReadEventsalarm-history → continuous- historization recorder (FasterLog outbox) →EnsureTagsprovisioning 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.