Files
lmxopcua/docs/plans/2026-06-14-galaxy-phase-c-historian-plan.md
T

301 lines
19 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.
# Galaxy Phase C — Server-side HistoryRead — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: use superpowers-extended-cc:subagent-driven-development (or executing-plans) to implement this plan task-by-task.
**Goal:** Answer OPC UA HistoryRead (Raw / Processed / AtTime / Events) for historized equipment
tags by dispatching to the existing `IHistorianDataSource` (Wonderware TCP client). Authoring via a
`"isHistorized"` flag in the existing `TagConfig` blob — **no UI, no EF migration**.
**Architecture:** Carry `isHistorized`/`historianTagname` through the Phase-B `alarm`-object seam
(`TagConfig``EquipmentTagPlan` → byte-parity artifact → `EnsureVariable`), set `Historizing` +
`AccessLevels.HistoryRead`, register a NodeId→tagname map, and override `CustomNodeManager2`'s
HistoryRead surface to block-bridge onto the async data source. Config-gated DI mirrors
`AddAlarmHistorian`; the Host supplies the Wonderware client.
**Tech Stack:** .NET 10, OPC Foundation `Opc.Ua.Server` (`CustomNodeManager2`), Akka-free node
manager, xUnit + Shouldly.
**Design:** `docs/plans/2026-06-14-galaxy-phase-c-historian-design.md` (read for the locked decisions).
---
## Execution notes (hard rules — every task)
- **Branch:** `feat/galaxy-phase-c-historian` (already created off master `c9a0f627`). Do NOT work on
master.
- **Stage by path; never `git add .`.** Never stage `sql_login.txt`,
`src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`, `pending.md`, `current.md`, or
`docker-dev/docker-compose.yml`. Never echo the gateway API key or historian `SharedSecret` into a
tracked file. No force-push, no `--no-verify`.
- **NO EF/Configuration entity or migration change.** The flag rides in `TagConfig` JSON.
- **NO bUnit** — there is no UI in Phase C; everything here is offline-unit-testable. The live `/run`
is deferred (Task 7).
- Production projects are `TreatWarningsAsErrors` — keep the build at **0 warnings** in `src/`.
- Each task: write the failing test, see it fail, implement minimally, see it pass, then commit by path.
---
### Task 0: Feature branch (already created)
**Classification:** trivial · **Estimated implement time:** ~0 min · **Parallelizable with:** none
Precondition met — `feat/galaxy-phase-c-historian` exists off master `c9a0f627`, design committed
`5e42c1bc`. No action.
---
### Task 1: Carry `isHistorized` + `historianTagname` (composer + artifact, byte-parity)
**Classification:** standard · **Estimated implement time:** ~5 min · **Parallelizable with:** none
Mirror the Phase B `alarm`-object carrier exactly.
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs`
- `EquipmentTagPlan` record (`:82-91`): add `bool IsHistorized` and `string? HistorianTagname` as
the last two positional fields (update the doc-comment to mention they parse identically on the
artifact side, like `Alarm`).
- Add `internal static (bool IsHistorized, string? HistorianTagname) ExtractTagHistorize(string?
tagConfig)` next to `ExtractTagAlarm` (`:461`): parse `isHistorized` (bool; absent/non-bool ⇒
false) and `historianTagname` (string; absent/blank/non-string ⇒ null). Never throws (catch
`JsonException` ⇒ `(false, null)`). Carry the **byte-parity** comment naming
`DeploymentArtifact.ExtractTagHistorize`.
- In the `equipmentTags` `.Select(...)` (`:331-340`): set `IsHistorized` / `HistorianTagname` from
`ExtractTagHistorize(t.TagConfig)`.
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs`
- Add a **byte-parity** `private static (bool, string?) ExtractTagHistorize(string? tagConfig)` next
to `ExtractTagAlarm` (`:658`), same parse, carrying the byte-parity comment naming the composer.
- In `BuildEquipmentTagPlans` (`:440-449`): thread the two new fields onto the `new
EquipmentTagPlan(...)`.
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagHistorizeTests.cs` (new; model on
`ExtractTagAlarmTests.cs`).
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactHistorizeParityTests.cs`
(new; model on `DeploymentArtifactAliasParityTests.cs`).
**Steps:**
1. Failing test `ExtractTagHistorizeTests`: `{"FullName":"T.A","isHistorized":true}` ⇒ `(true,null)`;
`+"historianTagname":"WW.Tag"` ⇒ `(true,"WW.Tag")`; absent ⇒ `(false,null)`; `"isHistorized":false`
⇒ `(false,null)`; malformed JSON / blank-tagname ⇒ `(false,null)` / `(true,null)`; never throws.
2. Run → fail (method missing). Implement `ExtractTagHistorize` + record fields + Select wiring. Run →
pass.
3. Failing test `DeploymentArtifactHistorizeParityTests`: build a snapshot with a historized tag (and
one with an override tagname), run it through `ConfigComposer`→artifact→`DeploymentArtifact`
decode, and assert the decoded `EquipmentTagPlan.IsHistorized`/`HistorianTagname` equal the
composer's for the same input (parity). Run → fail. Implement the artifact side. Run → pass.
4. `dotnet build` clean (0 warn); `dotnet test` the two projects green. Commit by path
(`Phase7Composer.cs`, `DeploymentArtifact.cs`, the two new test files).
---
### Task 2: Materialize historized — `Historizing` + `HistoryRead` bit + NodeId→tagname registry
**Classification:** standard · **Estimated implement time:** ~5 min · **Parallelizable with:** none
**Files:**
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs` — `EnsureVariable`
signature gains a trailing `string? historianTagname` param (doc: null ⇒ not historized). Update the
`NullOpcUaAddressSpaceSink` no-op impl in the same file.
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs` — pass the new param
through.
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs` — pass-through.
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs` — `SafeEnsureVariable` (`:294`)
gains the param; `MaterialiseEquipmentTags` (`:200`) computes
`string? historianTagname = tag.IsHistorized ? (string.IsNullOrWhiteSpace(tag.HistorianTagname) ?
tag.FullName : tag.HistorianTagname) : null;` and passes it.
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`
- Add `private readonly ConcurrentDictionary<string,string> _historizedTagnames = new(StringComparer.Ordinal);`
- `EnsureVariable` (`:820`): take `string? historianTagname`; when non-null set `Historizing = true`,
OR `AccessLevels.HistoryRead` into both `AccessLevel`+`UserAccessLevel` (keep the writable
ReadWrite composite), and `_historizedTagnames[variableNodeId] = historianTagname`. When null,
behavior is exactly as today (`Historizing=false`).
- `RebuildAddressSpace` (`:889`): `_historizedTagnames.Clear();` alongside the other clears.
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs` (extend) — or a
new `NodeManagerHistorizeTests.cs` if cleaner. Reuse the node-manager harness in `SdkAddressSpaceSinkTests`
/ `EquipmentWriteGateTests`.
**Steps:**
1. Failing test: materialize a variable with `historianTagname:"WW.Tag"` ⇒ the node's `Historizing`
is true and `(AccessLevel & AccessLevels.HistoryRead) != 0`; a null tagname ⇒ `Historizing` false,
no History bit. (Expose a read path: the existing tests already reach `BaseDataVariableState` via
the node manager — assert on that, or add a small `TryGetVariable` test accessor if none exists.)
2. Run → fail. Thread the param through all five seam files + node manager. Run → pass.
3. Failing test (applier): a historized `EquipmentTagPlan` with no override ⇒ the sink receives
`historianTagname == FullName`; with an override ⇒ receives the override. (Use a recording fake
`IOpcUaAddressSpaceSink`.) Run → fail → implement the applier resolve → pass.
4. Build clean; the `OpcUaServer.Tests` suite green (existing `EnsureVariable` callers updated for the
new param). Commit by path.
---
### Task 3: HistoryRead override — Raw / Processed / AtTime + `NullHistorianDataSource` + wiring prop
**Classification:** high-risk · **Estimated implement time:** ~6 min · **Parallelizable with:** none
This is the load-bearing OPC UA service-level task.
**Files:**
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/NullHistorianDataSource.cs` —
singleton `Instance`; all reads return empty `HistoryReadResult`/`HistoricalEventsResult` (empty
list, null continuation); `GetHealthSnapshot()` returns a disabled snapshot; `Dispose()` no-op.
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`
- Add `private volatile IHistorianDataSource _historianDataSource = NullHistorianDataSource.Instance;`
+ `public IHistorianDataSource HistorianDataSource { get => _historianDataSource; set =>
_historianDataSource = value ?? NullHistorianDataSource.Instance; }` (doc mirrors `NodeWriteGateway`).
- **Confirm the override surface** of the installed `OPCFoundation.NetStandard.Opc.Ua.Server` first:
query the DeepWiki MCP (`OPCFoundation/UA-.NETStandard`, per CLAUDE.md) AND inspect the actual
base members (e.g. `grep`/decompile the referenced `Opc.Ua.Server.CustomNodeManager2` for
`HistoryRead`). RECOMMENDED: override the per-details protected virtuals
(`HistoryReadRawModified`, `HistoryReadProcessed`, `HistoryReadAtTime`); FALLBACK: override the
single public `HistoryRead(...)` and switch on `details`. Whatever the chosen surface, do **not**
invoke base for nodes we own (`_historizedTagnames` hit); mark `Processed`/fill `results[i]`.
- Per node: resolve `NodeId.Identifier` (string) in `_historizedTagnames`; miss ⇒
`BadHistoryOperationUnsupported`. On hit, dispatch to `_historianDataSource` (block with
`.GetAwaiter().GetResult()` in `try`/`catch` → per-node `BadHistoryOperationUnsupported`/
`BadUnexpectedError` + log; never fault the batch). Map `DataValueSnapshot` →
`Opc.Ua.DataValue`; wrap in `HistoryData`; `results[i].HistoryData = new ExtensionObject(data)`;
`StatusCode = samples.Count == 0 ? GoodNoData : Good`; `ContinuationPoint = null`. Handle
`releaseContinuationPoints == true` ⇒ `Good` no-op.
- `MapAggregate(NodeId)` helper → `HistoryAggregateType`; unknown ⇒ `BadAggregateNotSupported`.
- `DataValueSnapshot→DataValue` mapping helper (value Variant, StatusCode, source+server ts).
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadTests.cs` (new) — a
recording/fake `IHistorianDataSource`.
- Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/NullHistorianDataSourceTests.cs`
(new).
**Steps:**
1. Confirm the override surface (DeepWiki + assembly). Write `NullHistorianDataSource` + its test
(empty results). Run → pass.
2. Failing test (Raw): materialize a historized node; set a fake source returning 2 samples; invoke
the HistoryRead override with a `ReadRawModifiedDetails` + one `HistoryReadValueId` for the node;
assert the fake got `(tagname == FullName, start, end, numValues)`, the result decodes to a
`HistoryData` of 2 `DataValue`s with mapped value/status/timestamps, `StatusCode == Good`.
3. Run → fail. Add the prop + override + Raw path + mappers. Run → pass.
4. Add tests + impl for: Processed (aggregate mapping, `BadAggregateNotSupported` on unknown), AtTime
(request times passed through), default-vs-override tagname, empty ⇒ `GoodNoData`, non-historized
node ⇒ `BadHistoryOperationUnsupported`, backend-throw ⇒ per-node bad + batch survives.
5. Build clean; `OpcUaServer.Tests` + `Core.Abstractions.Tests` green. Commit by path.
---
### Task 4: HistoryRead override — Events over equipment-folder notifiers
**Classification:** high-risk · **Estimated implement time:** ~6 min · **Parallelizable with:** Task 5
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`
- Add `private readonly ConcurrentDictionary<string,string> _eventNotifierSources = new(StringComparer.Ordinal);`
- `EnsureFolderIsEventNotifier` (`:749`): when `_historianDataSource is not NullHistorianDataSource`,
OR `EventNotifiers.HistoryRead` into `folder.EventNotifier` (keep `SubscribeToEvents`); register
`_eventNotifierSources[folder.NodeId.Identifier?.ToString()] = folder.NodeId.Identifier?.ToString()`
(the equipment id = `sourceName`). (Reads the data-source field — fine; it's set before
materialization in the Host. If a deployment materializes before wiring, the bit is simply absent
until the next rebuild — acceptable; document it.)
- `RebuildAddressSpace`: `_eventNotifierSources.Clear();`.
- Events dispatch (the `ReadEventDetails` arm of the chosen override surface): resolve the target
folder NodeId in `_eventNotifierSources` (miss ⇒ `BadHistoryOperationUnsupported`); call
`_historianDataSource.ReadEventsAsync(sourceName, start, end, maxEvents, ct)` (block + guard);
project each `HistoricalEvent` → `HistoryEventFieldList` per `ReadEventDetails.Filter.SelectClauses`
— support `BaseEventType` operands `EventId`, `SourceName`, `Time`, `Message`, `Severity`; an
unsupported operand ⇒ `null` field; wrap in `HistoryEvent { Events = … }`; `StatusCode = Good`
(or `GoodNoData` when empty).
- `ProjectEventField(HistoricalEvent, SimpleAttributeOperand)` helper.
- Test: extend `NodeManagerHistoryReadTests.cs` — events arm.
**Steps:**
1. Failing test: promote a folder to a notifier with a wired (fake non-Null) source; invoke the
override with a `ReadEventDetails` (a select clause for EventId/SourceName/Time/Message/Severity)
on the folder; assert the fake got `(sourceName == equipmentId, …)` and each returned
`HistoryEventFieldList` carries the projected fields in select-clause order; an unsupported operand
⇒ null field; empty ⇒ `GoodNoData`; a non-notifier node ⇒ `BadHistoryOperationUnsupported`.
2. Run → fail. Implement the `_eventNotifierSources` map + folder-promotion bit + the events arm +
projector. Run → pass.
3. Build clean; `OpcUaServer.Tests` green. Commit by path.
---
### Task 5: DI + config + Host wiring (`AddServerHistorian`, `SetHistorianDataSource`)
**Classification:** standard · **Estimated implement time:** ~5 min · **Parallelizable with:** Task 4
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs` — `SectionName =
"ServerHistorian"`; `Enabled`, `Host`, `Port`, `UseTls`, `ServerCertThumbprint`, `SharedSecret`;
`Validate()` warnings mirroring `AlarmHistorianOptions` (empty SharedSecret, etc.).
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs`
- `AddOtOpcUaRuntime`: `services.TryAddSingleton<IHistorianDataSource>(NullHistorianDataSource.Instance);`
- Add `AddServerHistorian(this IServiceCollection, IConfiguration, Func<ServerHistorianOptions,
IServiceProvider, IHistorianDataSource> factory)` — bind the section; `Enabled != true` ⇒ no-op
(Null stays); else log `Validate()` warnings + `AddSingleton<IHistorianDataSource>(sp =>
factory(opts, sp))`. Mirror `AddAlarmHistorian`.
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs` — add
`public bool SetHistorianDataSource(IHistorianDataSource? source)` mirroring `SetNodeWriteGateway`.
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` — after `AddAlarmHistorian` (`:94`), add
`AddServerHistorian(config, (opts,sp) => new WonderwareHistorianClient(new
WonderwareHistorianClientOptions(opts.Host, opts.Port, opts.SharedSecret){ UseTls=opts.UseTls,
ServerCertThumbprint=opts.ServerCertThumbprint }, sp.GetService<ILogger<WonderwareHistorianClient>>()))`.
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs` — after
`SetNodeWriteGateway` (`:147`), resolve `IHistorianDataSource` from DI and
`_server.SetHistorianDataSource(source)`; in `StopAsync` (`:166`) `_server?.SetHistorianDataSource(null)`.
(The hosted service must be able to resolve `IHistorianDataSource` — inject it or pull from the
provider exactly as the other sinks are obtained.)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json` — add a disabled `ServerHistorian`
block (documented defaults). **Do not** put a real `SharedSecret` in a tracked file (leave `""`).
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AddServerHistorianTests.cs` (new;
model on the existing `AddAlarmHistorian` DI test) — disabled ⇒ resolves `NullHistorianDataSource`;
enabled ⇒ resolves the factory's source.
**Steps:**
1. Failing DI test: `ServerHistorian:Enabled=false` ⇒ `IHistorianDataSource` is the Null instance;
`Enabled=true` ⇒ the factory's fake. Run → fail.
2. Implement `ServerHistorianOptions` + `AddServerHistorian` + the `TryAddSingleton` default + the
`SetHistorianDataSource` setter. Run → pass.
3. Wire the Host (`Program.cs` + hosted service + appsettings). Build the **Host** project to prove
the Wonderware factory + DI compile (no unit test for the Host wiring — it's covered by the live
gate). Build clean.
4. Commit by path (do NOT stage appsettings.json secrets — the block is disabled with empty secret).
---
### Task 6: Docs + bookkeeping
**Classification:** small · **Estimated implement time:** ~4 min · **Parallelizable with:** none
**Files:**
- Create: `docs/Historian.md` — the historized-tag `TagConfig` schema (`isHistorized`,
`historianTagname` default=FullName), the `ServerHistorian` config section, HistoryRead behavior
(the four variants, graceful degrade to `GoodNoData`, the single-shot/no-continuation limitation,
events-on-equipment-folders), and a `Client.CLI historyread` example.
- Modify: `CLAUDE.md` — a short pointer under a Historian/HistoryRead note (one paragraph + the doc link).
- Modify (disk-only, **do NOT commit**): `pending.md` — mark #2 Phase C done with the merge SHA +
the deferred live gate.
- Update: the plan's `.tasks.json` statuses.
**Steps:** Write the docs; commit `docs/Historian.md` + `CLAUDE.md` by path. Update `pending.md` on
disk only (never staged).
---
### Task 7: Live docker-dev `/run` verification — DEFERRED (user-driven)
**Classification:** n/a (live) · **Parallelizable with:** none · **Depends on:** Task 5
**Deferred — the agent does NOT sign in and the local docker-dev rig has no Wonderware sidecar.** The
historian backend is the WW Historian VM (`10.100.0.48`) + AVEVA, so this gate is operator-driven.
When run: author a historized Galaxy equipment tag (`TagConfig` `"isHistorized":true`), set
`ServerHistorian:Enabled=true` pointed at the sidecar (`Host`/`Port`/`SharedSecret`/TLS), deploy on
`MAIN-galaxy-eq` (`POST http://localhost:9200/api/deployments`, `X-Api-Key: docker-dev-deploy-key`),
then `Client.CLI historyread -u opc.tcp://localhost:4840 -n "ns=2;s=<equip>/<tag>" --start … --end …`
→ samples return; a non-historized tag ⇒ `BadHistoryOperationUnsupported`.
---
## Dependency graph
`0 → 1 → 2 → 3 → (4 ∥ 5) → 6 → 7(deferred)`. Tasks 4 and 5 touch disjoint files
(node-manager events arm vs. DI/Host/SdkServer) and may be dispatched concurrently after Task 3.
## Done
Build clean (0 warnings in `src/`) + `dotnet test` green (new + existing suites) for Tasks 16. The
live `/run` (Task 7) is the deferred user-driven gate.