369e832e5a
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
1369 lines
86 KiB
Markdown
1369 lines
86 KiB
Markdown
# OtOpcUa ↔ HistorianGateway Integration — Implementation Plan
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||
|
||
**Goal:** Make HistorianGateway the sole historian read/write backend for the OtOpcUa OPC UA server — replacing the bespoke Wonderware TCP/ArchestrA sidecar — via a new gateway-backed `IHistorianDataSource` + `IAlarmHistorianWriter` + `IHistorianProvisioning`, a continuous-historization recorder, and DI factory swaps.
|
||
|
||
**Architecture:** A new `ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway` driver project consumes the Gitea-feed `ZB.MOM.WW.HistorianGateway.Client` package (gRPC over `historian_gateway.v1`) behind a thin `IHistorianGatewayClient` seam so every adapter is unit-testable against a fake. Pure, matrix-guarded mappers (design §6) translate OPC UA / driver shapes to gateway proto shapes and back; the read adapter swaps into the existing `AddServerHistorian` factory (the independently-shippable read cutover), the alarm writer swaps into `AddAlarmHistorian` (behind the existing `SqliteStoreAndForwardSink`), and a new Akka recorder taps `DependencyMuxActor` value changes into a crash-safe FasterLog outbox that drains to `WriteLiveValues`. An `EnsureTags` provisioning hook fires non-blocking from `AddressSpaceApplier.Apply()`.
|
||
|
||
**Tech Stack:** .NET 10, OPC Foundation UA .NET Standard, Akka.NET actors, Microsoft.FASTER.Core (FasterLog outbox), ZB.MOM.WW.HistorianGateway.Client (gRPC), xUnit.
|
||
|
||
---
|
||
|
||
## Prerequisites & Constraints
|
||
|
||
**Cross-plan dependency (hard gate).** This plan DEPENDS ON the sibling gateway-client plan
|
||
(`docs/plans/2026-06-26-historian-gateway-client.md`) having already **published
|
||
`ZB.MOM.WW.HistorianGateway.Client` and `ZB.MOM.WW.HistorianGateway.Contracts` to the Gitea feed**
|
||
(`dohertj2-gitea`). Phase A consumes those packages; do not start until they resolve. Confirm with:
|
||
`dotnet package search ZB.MOM.WW.HistorianGateway.Client --source dohertj2-gitea`.
|
||
|
||
**Gateway deployment prerequisites (no code here — runtime config of the target gateway).**
|
||
The gateway OtOpcUa points at MUST run with:
|
||
- `RuntimeDb:Enabled=true` — enables the `WriteLiveValues` SQL live path (continuous historization).
|
||
- `RuntimeDb:EventReadsEnabled=true` — enables `ReadEvents` from `Runtime.dbo.Events` (alarm-history reads; C2 SQL workaround).
|
||
- An API key carrying scopes `historian:read`, `historian:write`, `historian:tags:write`.
|
||
|
||
The source-name filter on the SQL `ReadEvents` path is delivered by the gateway-client plan (its Task 14 / design §5.3); until it lands, `ReadEventsAsync(sourceName, …)` is time-range-only and filters client-side. T11 notes this.
|
||
|
||
**Engineering constraints (every task).**
|
||
- **Zero new warnings.** `Directory.Build.props` enforces `Nullable=enable`, `Platforms=x64`, `PlatformTarget=x64`, and treats new warnings as build breaks. **Fix, never suppress.**
|
||
- **Secrets via environment only.** The gateway API key, any connection strings, and TLS material are supplied via env vars (e.g. `ServerHistorian__ApiKey`). The committed appsettings defaults are blank or dev-only placeholders. Never commit a real key.
|
||
- **Redaction.** No hostnames, credentials, API keys, or tag values in error messages or logs by default. Map gateway/gRPC exceptions to safe messages — mirror the existing `WonderwareHistorianClient` posture.
|
||
- **Gitea, not GitHub.** `origin` is Gitea; `gh` will not work. Use `git push` to `origin`; open PRs via the Gitea API with `GITEA_TOKEN`.
|
||
- **Branch isolation.** OtOpcUa's working tree is currently DIRTY from unrelated in-flight work. This plan runs on its OWN fresh branch off OtOpcUa `main`:
|
||
```bash
|
||
cd ~/Desktop/OtOpcUa
|
||
git stash list # confirm you understand existing state; do NOT touch it
|
||
git fetch origin
|
||
git switch -c feat/historian-gateway-backend origin/main
|
||
```
|
||
Do not entangle with the dirty tree. If `origin/main` is unavailable, branch off the local `main` after confirming it is clean of the in-flight work.
|
||
- **Commit-message trailer.** Every commit message ends with:
|
||
`Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii`
|
||
- **Plan relocation.** This file (and its `.tasks.json`) live in the HistorianGateway repo while authored; when execution starts they relocate into `~/Desktop/OtOpcUa/docs/plans/`. All OtOpcUa paths below are absolute under `~/Desktop/OtOpcUa`.
|
||
- **Build/test anchors.** Solution is `~/Desktop/OtOpcUa/ZB.MOM.WW.OtOpcUa.slnx`. Build: `dotnet build ZB.MOM.WW.OtOpcUa.slnx`. Test a single class: `dotnet test --filter "FullyQualifiedName~<ClassName>"`.
|
||
|
||
**Reference implementations to mirror (read before touching the matching task).**
|
||
- Quality→OPC-UA-StatusCode: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Internal/QualityMapper.cs` (port the switch verbatim; the gateway `HistorianSample.opc_quality` is the same OPC DA quality byte).
|
||
- Aggregate-mode mapping + Total handling + AlignAtTime + null→BadNoData: `…Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs` (`MapAggregate`, `ReadProcessedAsync`, `AlignAtTimeSnapshots`, `ToAggregateSnapshots`).
|
||
- Health-counter discipline (single `_healthLock`, `TotalSuccesses+TotalFailures==TotalQueries`): same file, `GetHealthSnapshot` / `RecordOutcome`.
|
||
- FasterLog outbox: `~/Desktop/HistorianGateway/src/ZB.MOM.WW.HistorianGateway.Server/Historian/FasterLogOutboxStore.cs` (PerEntry/Periodic commit, append/peek/remove-truncate, RecoverState).
|
||
|
||
**The `IHistorianGatewayClient` seam.** All OtOpcUa adapters depend on a thin interface
|
||
`IHistorianGatewayClient` (defined in T1, in the Gateway driver project) that exposes exactly the
|
||
gateway operations OtOpcUa needs, in proto-typed terms:
|
||
|
||
```csharp
|
||
// uses ZB.MOM.WW.HistorianGateway.Contracts.Grpc types from the Contracts package
|
||
public interface IHistorianGatewayClient : IAsyncDisposable
|
||
{
|
||
IAsyncEnumerable<HistorianSample> ReadRawAsync(string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken ct);
|
||
IAsyncEnumerable<HistorianAggregateSample> ReadAggregateAsync(string tag, DateTime startUtc, DateTime endUtc, RetrievalMode mode, TimeSpan interval, CancellationToken ct);
|
||
Task<IReadOnlyList<HistorianSample>> ReadAtTimeAsync(string tag, IReadOnlyList<DateTime> timestampsUtc, CancellationToken ct);
|
||
IAsyncEnumerable<HistorianEvent> ReadEventsAsync(string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken ct);
|
||
Task<WriteAck> WriteLiveValuesAsync(string tag, IReadOnlyList<HistorianLiveValue> values, CancellationToken ct);
|
||
Task<WriteAck> SendEventAsync(HistorianEvent evt, CancellationToken ct);
|
||
Task<TagOperationResults> EnsureTagsAsync(IReadOnlyList<HistorianTagDefinition> definitions, CancellationToken ct);
|
||
Task<bool> ProbeAsync(CancellationToken ct);
|
||
Task<ConnectionStatus> GetConnectionStatusAsync(CancellationToken ct);
|
||
}
|
||
```
|
||
|
||
The concrete `HistorianGatewayClientAdapter : IHistorianGatewayClient` (wrapping the package's
|
||
`HistorianGatewayClient`) lands in T10 where the factory wires it. Every other adapter/recorder is
|
||
TDD'd against an in-memory `FakeHistorianGatewayClient` test double.
|
||
|
||
---
|
||
|
||
### Task 1: Consume the gateway packages + scaffold the Gateway driver project
|
||
**Classification:** small
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (every later task builds on this project)
|
||
|
||
**Files:**
|
||
- Modify `/Users/dohertj2/Desktop/OtOpcUa/NuGet.config`
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj`
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/IHistorianGatewayClient.cs`
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.csproj`
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/FakeHistorianGatewayClient.cs`
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/ProjectSmokeTests.cs`
|
||
- Modify `/Users/dohertj2/Desktop/OtOpcUa/ZB.MOM.WW.OtOpcUa.slnx`
|
||
|
||
**Step 1: failing test** — `ProjectSmokeTests.cs`:
|
||
```csharp
|
||
using Xunit;
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
|
||
public sealed class ProjectSmokeTests
|
||
{
|
||
[Fact]
|
||
public void GatewayClientSeam_IsReferenceable()
|
||
{
|
||
// Compiles only if the project references the Contracts package and the seam exists.
|
||
var t = typeof(IHistorianGatewayClient);
|
||
Assert.Equal("IHistorianGatewayClient", t.Name);
|
||
}
|
||
}
|
||
```
|
||
`FakeHistorianGatewayClient.cs` implements `IHistorianGatewayClient` with settable result fields + recorded-call lists (the reusable double for all later tasks): each method returns from a public `Func`/queue field defaulting to empty, and records its arguments.
|
||
|
||
**Step 2: run, expect FAIL** — `dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~ProjectSmokeTests"` → FAILS to compile/restore (project/package/seam absent).
|
||
|
||
**Step 3: implement**
|
||
1. In `NuGet.config`, add to the `dohertj2-gitea` `<packageSource>` block:
|
||
```xml
|
||
<package pattern="ZB.MOM.WW.HistorianGateway.Contracts" />
|
||
<package pattern="ZB.MOM.WW.HistorianGateway.Client" />
|
||
```
|
||
2. New `…Driver.Historian.Gateway.csproj` (mirror a sibling driver csproj's TFM/props; do NOT set its own `Nullable`/`Platforms` — `Directory.Build.props` supplies them):
|
||
- `<PackageReference Include="ZB.MOM.WW.HistorianGateway.Client" Version="0.1.0" />` (resolve the actual published version with `dotnet package search`; the Client transitively brings `.Contracts`).
|
||
- `<ProjectReference>` to `Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions` and `Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian`.
|
||
3. `IHistorianGatewayClient.cs` — the interface exactly as in Prerequisites (proto-typed; `using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;`).
|
||
4. Test csproj: references the Gateway driver project + `Microsoft.NET.Test.Sdk`, `xunit`, `xunit.runner.visualstudio` (copy versions from a sibling `*.Tests.csproj`).
|
||
5. `FakeHistorianGatewayClient` as described.
|
||
6. Add both projects to the solution: `dotnet sln ZB.MOM.WW.OtOpcUa.slnx add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.csproj`.
|
||
|
||
**Step 4: run, expect PASS** — same filter → PASS; `dotnet build ZB.MOM.WW.OtOpcUa.slnx` clean (0 warnings).
|
||
|
||
**Step 5: commit**
|
||
```bash
|
||
git add NuGet.config ZB.MOM.WW.OtOpcUa.slnx src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests
|
||
git commit -m "feat(historian-gateway): scaffold Gateway driver project + consume client package
|
||
|
||
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: `HistoryAggregateType` → `RetrievalMode` mapper (matrix-guarded)
|
||
**Classification:** small
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** Task 3, Task 4, Task 5, Task 6
|
||
|
||
**Files:**
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/AggregateModeMapper.cs`
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/AggregateModeMapperTests.cs`
|
||
|
||
**Step 1: failing test** — exhaustive per-member + matrix guard:
|
||
```csharp
|
||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; // HistoryAggregateType
|
||
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc; // RetrievalMode
|
||
using Xunit;
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.Mapping;
|
||
public sealed class AggregateModeMapperTests
|
||
{
|
||
[Theory]
|
||
[InlineData(HistoryAggregateType.Average, RetrievalMode.TimeWeightedAverage)]
|
||
[InlineData(HistoryAggregateType.Minimum, RetrievalMode.MinimumWithTime)]
|
||
[InlineData(HistoryAggregateType.Maximum, RetrievalMode.MaximumWithTime)]
|
||
[InlineData(HistoryAggregateType.Total, RetrievalMode.Integral)]
|
||
[InlineData(HistoryAggregateType.Count, RetrievalMode.Counter)]
|
||
public void Maps_each_aggregate(HistoryAggregateType a, RetrievalMode expected)
|
||
=> Assert.Equal(expected, AggregateModeMapper.ToRetrievalMode(a));
|
||
|
||
[Fact] // matrix guard: a new HistoryAggregateType member must fail here
|
||
public void Every_aggregate_member_is_mapped()
|
||
{
|
||
foreach (var a in Enum.GetValues<HistoryAggregateType>())
|
||
_ = AggregateModeMapper.ToRetrievalMode(a); // must not throw for any defined member
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~AggregateModeMapperTests"` → FAILS (mapper absent).
|
||
|
||
**Step 3: implement** — `AggregateModeMapper.ToRetrievalMode(HistoryAggregateType)`: a `switch` expression mapping the five members per the table; the `_ =>` arm throws `ArgumentOutOfRangeException` (so a future enum member fails the matrix guard, not silently mis-maps). Verify against `WonderwareHistorianClient.MapAggregate`: Average/Min/Max line up; the Wonderware path had no native Total (derived Average×interval) and used `ValueCount` for Count — the gateway's native `RetrievalMode.Integral` (Total) and `RetrievalMode.Counter` (Count) replace those client-side workarounds, so the gateway path is a strict improvement. Add an XML doc note recording that Total/Count are now native modes (no client-side scaling).
|
||
|
||
**Step 4: run, expect PASS.**
|
||
|
||
**Step 5: commit**
|
||
```bash
|
||
git commit -am "feat(historian-gateway): HistoryAggregateType->RetrievalMode mapper (matrix-guarded)
|
||
|
||
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: `DriverDataType` → `HistorianDataType` mapper with write-gap fallbacks (matrix-guarded)
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 2, Task 4, Task 5, Task 6
|
||
|
||
**Files:**
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/HistorianTypeMapper.cs`
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/HistorianTypeMapperTests.cs`
|
||
|
||
**Step 1: failing test** — per-member mapping + deferred-throws + matrix guard:
|
||
```csharp
|
||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; // DriverDataType
|
||
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc; // HistorianDataType
|
||
using Xunit;
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.Mapping;
|
||
public sealed class HistorianTypeMapperTests
|
||
{
|
||
[Theory]
|
||
[InlineData(DriverDataType.Boolean, HistorianDataType.Int1)]
|
||
[InlineData(DriverDataType.Int16, HistorianDataType.Int2)]
|
||
[InlineData(DriverDataType.Int32, HistorianDataType.Int4)]
|
||
[InlineData(DriverDataType.Int64, HistorianDataType.Int8)]
|
||
[InlineData(DriverDataType.UInt16, HistorianDataType.Uint4)] // fallback: UInt2 write deferred upstream
|
||
[InlineData(DriverDataType.UInt32, HistorianDataType.Uint4)]
|
||
[InlineData(DriverDataType.UInt64, HistorianDataType.Uint8)]
|
||
[InlineData(DriverDataType.Float32, HistorianDataType.Float)]
|
||
[InlineData(DriverDataType.Float64, HistorianDataType.Double)]
|
||
public void Maps_writable_numeric_types(DriverDataType d, HistorianDataType expected)
|
||
=> Assert.Equal(expected, HistorianTypeMapper.ToHistorianDataType(d));
|
||
|
||
[Theory]
|
||
[InlineData(DriverDataType.String)]
|
||
[InlineData(DriverDataType.DateTime)]
|
||
[InlineData(DriverDataType.Reference)]
|
||
public void Deferred_types_throw_NotSupported_with_clear_message(DriverDataType d)
|
||
{
|
||
var ex = Assert.Throws<NotSupportedException>(() => HistorianTypeMapper.ToHistorianDataType(d));
|
||
Assert.Contains("not historized in v1", ex.Message); // human-actionable, no tag value leaked
|
||
}
|
||
|
||
[Fact] // matrix guard: a new DriverDataType member must be classified (mapped or explicitly deferred)
|
||
public void Every_DriverDataType_member_is_classified()
|
||
{
|
||
foreach (var d in Enum.GetValues<DriverDataType>())
|
||
{
|
||
try { _ = HistorianTypeMapper.ToHistorianDataType(d); }
|
||
catch (NotSupportedException) { /* explicitly deferred — acceptable */ }
|
||
// any OTHER exception (e.g. ArgumentOutOfRangeException from an unhandled new member) fails the test
|
||
}
|
||
}
|
||
}
|
||
```
|
||
> Note: protobuf C# generates `HistorianDataType.Uint4` / `Uint8` (single capital U). Confirm the generated casing against the resolved Contracts package and align the `InlineData`.
|
||
|
||
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~HistorianTypeMapperTests"` → FAILS.
|
||
|
||
**Step 3: implement** — `HistorianTypeMapper.ToHistorianDataType(DriverDataType)`: `switch` per the §6 table; the three deferred members throw `NotSupportedException` with a message like `$"DriverDataType.{d} is not historized in v1 (string/datetime/reference writes are deferred — gated on the analog SQL write path)."` (no tag name/value in the message). The default arm also throws `NotSupportedException` referencing the unmapped member, so the matrix guard catches a future enum addition. Add a same-file `bool IsHistorizable(DriverDataType)` helper (true for the nine numeric members) — T15's provisioning hook uses it to skip deferred types without catching exceptions.
|
||
|
||
**Step 4: run, expect PASS.**
|
||
|
||
**Step 5: commit**
|
||
```bash
|
||
git commit -am "feat(historian-gateway): DriverDataType->HistorianDataType mapper + write-gap fallbacks (matrix-guarded)
|
||
|
||
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: `HistorianSample`/`HistorianAggregateSample` → `DataValueSnapshot` + quality mapper
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 2, Task 3, Task 5, Task 6
|
||
|
||
**Files:**
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/GatewayQualityMapper.cs`
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/SampleMapper.cs`
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/GatewayQualityMapperTests.cs`
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/SampleMapperTests.cs`
|
||
|
||
**Step 1: failing test** — quality parity (port the Wonderware table) + sample/aggregate shape:
|
||
```csharp
|
||
public sealed class GatewayQualityMapperTests
|
||
{
|
||
[Theory]
|
||
[InlineData(192, 0x00000000u)] // Good
|
||
[InlineData(216, 0x00D80000u)] // Good_LocalOverride
|
||
[InlineData(64, 0x40000000u)] // Uncertain
|
||
[InlineData(0, 0x80000000u)] // Bad
|
||
[InlineData(8, 0x808A0000u)] // Bad_NotConnected
|
||
[InlineData(255, 0x00000000u)] // >=192 bucket
|
||
[InlineData(100, 0x40000000u)] // >=64 bucket
|
||
[InlineData(1, 0x80000000u)] // bad bucket
|
||
public void Maps_opc_quality_byte(byte q, uint expected)
|
||
=> Assert.Equal(expected, GatewayQualityMapper.Map(q));
|
||
}
|
||
|
||
public sealed class SampleMapperTests
|
||
{
|
||
[Fact]
|
||
public void Numeric_sample_maps_value_quality_and_timestamps()
|
||
{
|
||
var s = new HistorianSample { Tag = "T", NumericValue = 12.5,
|
||
Quality = 192, OpcQuality = 192, Timestamp = Ts(2026,1,1,0,0,0) };
|
||
var snap = SampleMapper.ToSnapshot(s);
|
||
Assert.Equal(12.5, Assert.IsType<double>(snap.Value));
|
||
Assert.Equal(0x00000000u, snap.StatusCode);
|
||
Assert.Equal(DateTimeKind.Utc, snap.SourceTimestampUtc!.Value.Kind);
|
||
}
|
||
|
||
[Fact]
|
||
public void String_sample_carries_string_value()
|
||
{
|
||
var s = new HistorianSample { Tag = "T", StringValue = "abc", OpcQuality = 192, Timestamp = Ts(2026,1,1,0,0,0) };
|
||
Assert.Equal("abc", SampleMapper.ToSnapshot(s).Value);
|
||
}
|
||
|
||
[Fact]
|
||
public void Aggregate_null_value_is_BadNoData()
|
||
{
|
||
var a = new HistorianAggregateSample { Tag = "T", /* Value unset */ EndTime = Ts(2026,1,1,0,0,0) };
|
||
var snap = SampleMapper.ToAggregateSnapshot(a);
|
||
Assert.Equal(0x800E0000u, snap.StatusCode); // BadNoData
|
||
Assert.Null(snap.Value);
|
||
}
|
||
// Ts(...) builds a Google.Protobuf.WellKnownTypes.Timestamp from UTC parts.
|
||
}
|
||
```
|
||
|
||
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~SampleMapperTests|FullyQualifiedName~GatewayQualityMapperTests"` → FAILS.
|
||
|
||
**Step 3: implement**
|
||
- `GatewayQualityMapper.Map(byte)` — **byte-identical port** of `…Wonderware.Client/Internal/QualityMapper.cs` (copy the switch verbatim; add an XML doc cross-reference to that origin so a future quality-table change stays in parity).
|
||
- `SampleMapper.ToSnapshot(HistorianSample)` → `DataValueSnapshot(Value, StatusCode, SourceTimestampUtc, ServerTimestampUtc)`:
|
||
- `Value`: `s.NumericValue` (when present via proto3 optional `HasNumericValue`) boxed as `double`, else `s.StringValue` when present, else `null`.
|
||
- `StatusCode`: `GatewayQualityMapper.Map((byte)s.OpcQuality)` (prefer `opc_quality`; if zero/unset and `quality` carries the OPC-DA byte, fall back to `quality` — match whatever the gateway populates; document the choice).
|
||
- `SourceTimestampUtc`: `s.Timestamp.ToDateTime()` (UTC kind).
|
||
- `ServerTimestampUtc`: `DateTime.UtcNow`.
|
||
- `SampleMapper.ToAggregateSnapshot(HistorianAggregateSample)` → null aggregate value ⇒ `StatusCode 0x800E0000` (BadNoData), non-null ⇒ Good (`0x00000000`) with the value; `SourceTimestampUtc` ← the bucket end/start timestamp (match the Wonderware `ToAggregateSnapshots` convention — it stamps the bucket timestamp). Provide `IReadOnlyList<>` batch helpers too.
|
||
|
||
**Step 4: run, expect PASS.**
|
||
|
||
**Step 5: commit**
|
||
```bash
|
||
git commit -am "feat(historian-gateway): sample/aggregate->DataValueSnapshot + quality mapper (Wonderware parity)
|
||
|
||
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: `HistorianEvent` → `HistoricalEvent` mapper (+ severity from properties)
|
||
**Classification:** small
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** Task 2, Task 3, Task 4, Task 6
|
||
|
||
**Files:**
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/EventMapper.cs`
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/EventMapperTests.cs`
|
||
|
||
**Step 1: failing test:**
|
||
```csharp
|
||
public sealed class EventMapperTests
|
||
{
|
||
[Fact]
|
||
public void Maps_core_fields_and_times()
|
||
{
|
||
var e = new HistorianEvent { Id = "E1", SourceName = "Pump1",
|
||
EventTime = Ts(2026,1,1,0,0,0), ReceivedTime = Ts(2026,1,1,0,0,5) };
|
||
e.Properties["Message"] = "High temp";
|
||
e.Properties["Severity"] = "700";
|
||
var h = EventMapper.ToHistoricalEvent(e);
|
||
Assert.Equal("E1", h.EventId);
|
||
Assert.Equal("Pump1", h.SourceName);
|
||
Assert.Equal("High temp", h.Message);
|
||
Assert.Equal((ushort)700, h.Severity);
|
||
Assert.Equal(DateTimeKind.Utc, h.EventTimeUtc.Kind);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("Priority", "999", 999)]
|
||
[InlineData("Severity", "0", 1)] // clamp to OPC UA min 1
|
||
[InlineData("Severity", "5000", 1000)] // clamp to OPC UA max 1000
|
||
[InlineData(null, null, 1)] // missing → default min severity
|
||
public void Severity_parsed_and_clamped(string? key, string? val, int expected)
|
||
{
|
||
var e = new HistorianEvent { Id = "E", EventTime = Ts(2026,1,1,0,0,0), ReceivedTime = Ts(2026,1,1,0,0,0) };
|
||
if (key is not null) e.Properties[key] = val!;
|
||
Assert.Equal((ushort)expected, EventMapper.ToHistoricalEvent(e).Severity);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~EventMapperTests"` → FAILS.
|
||
|
||
**Step 3: implement** — `EventMapper.ToHistoricalEvent(HistorianEvent)`:
|
||
- `EventId` ← `e.Id`; `SourceName` ← `e.SourceName`; `EventTimeUtc` ← `e.EventTime.ToDateTime()`; `ReceivedTimeUtc` ← `e.ReceivedTime.ToDateTime()`.
|
||
- `Message` ← `Properties["Message"]` if present, else `e.Type` (best-effort render); never null-crash.
|
||
- `Severity` ← parse `Properties["Severity"]` else `Properties["Priority"]`; clamp to `[1,1000]`; missing/unparseable ⇒ `1`. Return `(ushort)`.
|
||
- Add a batch helper `IReadOnlyList<HistoricalEvent> ToHistoricalEvents(IEnumerable<HistorianEvent>)`.
|
||
|
||
**Step 4: run, expect PASS.**
|
||
|
||
**Step 5: commit**
|
||
```bash
|
||
git commit -am "feat(historian-gateway): HistorianEvent->HistoricalEvent mapper (+ clamped severity)
|
||
|
||
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: `AlarmHistorianEvent` → `HistorianEvent` mapper (SendEvent)
|
||
**Classification:** small
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** Task 2, Task 3, Task 4, Task 5
|
||
|
||
**Files:**
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/AlarmEventMapper.cs`
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/AlarmEventMapperTests.cs`
|
||
|
||
**Step 1: failing test:**
|
||
```csharp
|
||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; // AlarmHistorianEvent
|
||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; // AlarmSeverity
|
||
public sealed class AlarmEventMapperTests
|
||
{
|
||
[Fact]
|
||
public void Maps_source_time_type_and_rich_properties()
|
||
{
|
||
var a = new AlarmHistorianEvent("A1","Area/Line/Pump1","HiHi","LimitAlarm",
|
||
AlarmSeverity.High, "Activated", "Temp high", "operator1", "ack note",
|
||
new DateTime(2026,1,1,0,0,0,DateTimeKind.Utc));
|
||
var e = AlarmEventMapper.ToHistorianEvent(a);
|
||
Assert.Equal("Area/Line/Pump1", e.SourceName);
|
||
Assert.Equal("LimitAlarm", e.Type);
|
||
Assert.Equal(new DateTime(2026,1,1,0,0,0,DateTimeKind.Utc), e.EventTime.ToDateTime());
|
||
Assert.Equal("HiHi", e.Properties["AlarmName"]);
|
||
Assert.Equal("Activated", e.Properties["EventKind"]);
|
||
Assert.Equal("High", e.Properties["Severity"]);
|
||
Assert.Equal("operator1", e.Properties["User"]);
|
||
Assert.Equal("ack note", e.Properties["Comment"]);
|
||
Assert.Equal("Temp high", e.Properties["Message"]);
|
||
}
|
||
|
||
[Fact]
|
||
public void Null_comment_is_omitted_not_null()
|
||
{
|
||
var a = new AlarmHistorianEvent("A","S","N","DiscreteAlarm",AlarmSeverity.Low,"Cleared","m","system",null,
|
||
new DateTime(2026,1,1,0,0,0,DateTimeKind.Utc));
|
||
Assert.False(AlarmEventMapper.ToHistorianEvent(a).Properties.ContainsKey("Comment"));
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~AlarmEventMapperTests"` → FAILS.
|
||
|
||
**Step 3: implement** — `AlarmEventMapper.ToHistorianEvent(AlarmHistorianEvent)`:
|
||
- `Id` ← `a.AlarmId` (or `Guid.NewGuid().ToString("N")` if blank); `SourceName` ← `a.EquipmentPath`; `EventTime` ← `Timestamp.FromDateTime(DateTime.SpecifyKind(a.TimestampUtc, DateTimeKind.Utc))`; `ReceivedTime` ← same as event time (server re-stamps on the SQL path); `Type` ← `a.AlarmTypeName`.
|
||
- `Properties` map: `AlarmName`, `EventKind`, `Severity` (`a.Severity.ToString()`), `User`, `Message`; add `Comment` only when non-null. Proto `map<string,string>` values must be non-null — never insert a null.
|
||
|
||
**Step 4: run, expect PASS.**
|
||
|
||
**Step 5: commit**
|
||
```bash
|
||
git commit -am "feat(historian-gateway): AlarmHistorianEvent->HistorianEvent mapper (SendEvent properties)
|
||
|
||
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: `GatewayHistorianDataSource` — ReadRaw / ReadProcessed / ReadAtTime
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (consumes T2 + T4; gates T8/T10/T11)
|
||
|
||
**Files:**
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs`
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayHistorianDataSourceTests.cs`
|
||
|
||
**Step 1: failing test** — against `FakeHistorianGatewayClient`:
|
||
```csharp
|
||
public sealed class GatewayHistorianDataSourceTests
|
||
{
|
||
[Fact]
|
||
public async Task ReadRaw_maps_samples_and_passes_args()
|
||
{
|
||
var fake = new FakeHistorianGatewayClient();
|
||
fake.RawSamples = new[] {
|
||
new HistorianSample { Tag="T", NumericValue=1.0, OpcQuality=192, Timestamp=Ts(2026,1,1,0,0,0) },
|
||
new HistorianSample { Tag="T", NumericValue=2.0, OpcQuality=0, Timestamp=Ts(2026,1,1,0,0,1) },
|
||
};
|
||
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
||
var r = await ds.ReadRawAsync("T", DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow, 100, default);
|
||
Assert.Equal(2, r.Samples.Count);
|
||
Assert.Equal(0x80000000u, r.Samples[1].StatusCode); // Bad from quality 0
|
||
Assert.Equal("T", fake.LastReadRawTag);
|
||
Assert.Equal(100, fake.LastReadRawMaxValues);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ReadProcessed_uses_aggregate_mode_mapping()
|
||
{
|
||
var fake = new FakeHistorianGatewayClient();
|
||
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
||
await ds.ReadProcessedAsync("T", default, default, TimeSpan.FromSeconds(60), HistoryAggregateType.Minimum, default);
|
||
Assert.Equal(RetrievalMode.MinimumWithTime, fake.LastAggregateMode);
|
||
Assert.Equal(TimeSpan.FromSeconds(60), fake.LastAggregateInterval);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ReadAtTime_aligns_one_snapshot_per_timestamp_with_gaps_Bad()
|
||
{
|
||
var fake = new FakeHistorianGatewayClient();
|
||
var t0 = new DateTime(2026,1,1,0,0,0,DateTimeKind.Utc);
|
||
var t1 = t0.AddSeconds(1);
|
||
fake.AtTimeSamples = new[] { new HistorianSample { NumericValue=5.0, OpcQuality=192, Timestamp=Timestamp.FromDateTime(t0) } };
|
||
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
||
var r = await ds.ReadAtTimeAsync("T", new[]{ t0, t1 }, default);
|
||
Assert.Equal(2, r.Samples.Count); // exactly one per requested ts, in order
|
||
Assert.Equal(5.0, r.Samples[0].Value);
|
||
Assert.Equal(0x80000000u, r.Samples[1].StatusCode); // gap → Bad at requested ts
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Empty_window_is_not_a_fault()
|
||
{
|
||
var fake = new FakeHistorianGatewayClient { RawSamples = Array.Empty<HistorianSample>() };
|
||
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
||
var r = await ds.ReadRawAsync("T", default, default, 10, default);
|
||
Assert.Empty(r.Samples); // GoodNoData-empty, no throw
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~GatewayHistorianDataSourceTests"` → FAILS.
|
||
|
||
**Step 3: implement** — `GatewayHistorianDataSource : IHistorianDataSource` (ctor takes `IHistorianGatewayClient` + `ILogger<>`):
|
||
- `ReadRawAsync`: clamp `maxValuesPerNode` to `int` (`(int)Math.Min(maxValuesPerNode, int.MaxValue)`); drain `client.ReadRawAsync` `IAsyncEnumerable` → `SampleMapper.ToSnapshot`; return `new HistoryReadResult(list, ContinuationPoint: null)`.
|
||
- `ReadProcessedAsync`: `AggregateModeMapper.ToRetrievalMode(aggregate)` → `client.ReadAggregateAsync(...)`; map via `SampleMapper.ToAggregateSnapshot`. (No client-side Total scaling — `Integral` is native; delete the Wonderware workaround.)
|
||
- `ReadAtTimeAsync`: call `client.ReadAtTimeAsync`; **align exactly one snapshot per requested timestamp in order**, gaps → Bad (`0x80000000`) stamped at the requested time — **port `WonderwareHistorianClient.AlignAtTimeSnapshots` verbatim** (index returned samples by `Timestamp.ToDateTime().Ticks`).
|
||
- Record health counters on every read via a private `RecordOutcome` mirroring the Wonderware single-lock discipline (used by T8). Map a thrown gateway exception to a recorded failure + rethrow (the node manager turns it into a Bad result; never crash the host).
|
||
- `Dispose()` disposes the client (bridge the `IAsyncDisposable` like the Wonderware `Dispose`).
|
||
|
||
**Step 4: run, expect PASS.**
|
||
|
||
**Step 5: commit**
|
||
```bash
|
||
git commit -am "feat(historian-gateway): GatewayHistorianDataSource read paths (raw/processed/at-time)
|
||
|
||
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: `GetHealthSnapshot` from Probe / GetConnectionStatus
|
||
**Classification:** small
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** none (extends T7's class)
|
||
|
||
**Files:**
|
||
- Modify `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs`
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayHealthSnapshotTests.cs`
|
||
|
||
**Step 1: failing test:**
|
||
```csharp
|
||
public sealed class GatewayHealthSnapshotTests
|
||
{
|
||
[Fact]
|
||
public async Task Counters_track_success_and_failure()
|
||
{
|
||
var fake = new FakeHistorianGatewayClient { RawSamples = Array.Empty<HistorianSample>() };
|
||
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
||
await ds.ReadRawAsync("T", default, default, 1, default);
|
||
fake.ThrowOnRead = true;
|
||
await Assert.ThrowsAnyAsync<Exception>(() => ds.ReadRawAsync("T", default, default, 1, default));
|
||
var h = ds.GetHealthSnapshot();
|
||
Assert.Equal(2, h.TotalQueries);
|
||
Assert.Equal(1, h.TotalSuccesses);
|
||
Assert.Equal(1, h.TotalFailures);
|
||
Assert.Equal(1, h.ConsecutiveFailures);
|
||
Assert.Equal(h.TotalQueries, h.TotalSuccesses + h.TotalFailures); // invariant
|
||
}
|
||
|
||
[Fact]
|
||
public void Connection_state_reflects_GetConnectionStatus_flags()
|
||
{
|
||
var fake = new FakeHistorianGatewayClient {
|
||
ConnectionStatus = new ConnectionStatus { ConnectedToServer = true, ConnectionKind = 0b11 } }; // Process|Event
|
||
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
||
ds.RefreshConnectionStateAsync(default).GetAwaiter().GetResult(); // internal probe used by health hosted-service
|
||
var h = ds.GetHealthSnapshot();
|
||
Assert.True(h.ProcessConnectionOpen);
|
||
Assert.True(h.EventConnectionOpen);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~GatewayHealthSnapshotTests"` → FAILS.
|
||
|
||
**Step 3: implement**
|
||
- Add the six health counter fields under one `_healthLock` + `RecordOutcome(bool, string?)` (copy the Wonderware discipline so `TotalSuccesses + TotalFailures == TotalQueries` always holds). Wire `RecordOutcome` into every read method from T7.
|
||
- Add a lightweight, non-blocking cached connection state: `RefreshConnectionStateAsync(ct)` calls `client.GetConnectionStatusAsync` (and/or `ProbeAsync`) and caches `ProcessConnectionOpen` ← `ConnectedToServer && (ConnectionKind & 1)!=0`, `EventConnectionOpen` ← `ConnectedToServer && (ConnectionKind & 2)!=0`. `GetHealthSnapshot()` is **pure observation** — returns the cached flags + counters; it never blocks on I/O (per the interface contract). `ActiveProcessNode`/`ActiveEventNode`/`Nodes` are null/empty (the gateway is non-clustered to us, mirroring the Wonderware client's Finding 010 posture).
|
||
|
||
**Step 4: run, expect PASS.**
|
||
|
||
**Step 5: commit**
|
||
```bash
|
||
git commit -am "feat(historian-gateway): GetHealthSnapshot via Probe/GetConnectionStatus (counter discipline)
|
||
|
||
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Reshape `ServerHistorianOptions` to gateway form
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 2–Task 8 (different project/area)
|
||
|
||
**Files:**
|
||
- Modify `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs`
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ServerHistorianOptionsTests.cs` (place in the existing Runtime test project; confirm its path with `find tests -name "*Runtime.Tests.csproj"`)
|
||
|
||
**Step 1: failing test:**
|
||
```csharp
|
||
public sealed class ServerHistorianOptionsTests
|
||
{
|
||
[Fact]
|
||
public void Disabled_yields_no_warnings()
|
||
=> Assert.Empty(new ServerHistorianOptions { Enabled = false }.Validate());
|
||
|
||
[Fact]
|
||
public void Enabled_without_endpoint_warns()
|
||
{
|
||
var w = new ServerHistorianOptions { Enabled = true, Endpoint = "", ApiKey = "histgw_x_y" }.Validate();
|
||
Assert.Contains(w, m => m.Contains("Endpoint"));
|
||
}
|
||
|
||
[Fact]
|
||
public void Enabled_without_apikey_warns()
|
||
{
|
||
var w = new ServerHistorianOptions { Enabled = true, Endpoint = "https://h:5222", ApiKey = "" }.Validate();
|
||
Assert.Contains(w, m => m.Contains("ApiKey"));
|
||
}
|
||
|
||
[Fact]
|
||
public void Valid_config_is_clean()
|
||
=> Assert.Empty(new ServerHistorianOptions { Enabled = true, Endpoint = "https://h:5222", ApiKey = "histgw_x_y" }.Validate());
|
||
}
|
||
```
|
||
|
||
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~ServerHistorianOptionsTests"` → FAILS (new members absent).
|
||
|
||
**Step 3: implement** — reshape `ServerHistorianOptions`:
|
||
- **Add:** `string Endpoint` (e.g. `https://host:5222`), `string ApiKey = ""` (supplied via env `ServerHistorian__ApiKey`), `bool UseTls = true`, `bool AllowUntrustedServerCertificate = false`, `string? CaCertificatePath`, `TimeSpan CallTimeout = TimeSpan.FromSeconds(30)`.
|
||
- **Remove:** `Host`, `Port`, `SharedSecret`, `ServerCertThumbprint` (Wonderware-specific). **Keep** `Enabled` and `MaxTieClusterOverfetch` (still used by the node manager's HistoryRead-Raw tie-cluster paging — unchanged).
|
||
- `Validate()`: warn when enabled and `Endpoint` blank; warn when enabled and `ApiKey` blank ("the gateway gRPC surface will reject unauthenticated calls"); keep the `MaxTieClusterOverfetch <= 0` warning. **No secret values in warning text.**
|
||
- Update the class XML doc to describe the gateway backend (not the Wonderware sidecar).
|
||
|
||
> High-risk: this is a config contract change. Any deployment `appsettings` `ServerHistorian` block changes shape (T21 documents the new keys + provides a migration note). The build will surface every stale reference to the removed members — the only in-tree consumer is the Program.cs factory (T10) and the (to-be-retired) Wonderware wiring.
|
||
|
||
**Step 4: run, expect PASS** — filter passes; `dotnet build` of the Runtime project clean. (Program.cs will not compile until T10 — that is expected and handled there; build the Runtime + Tests projects in isolation for this task: `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ZB.MOM.WW.OtOpcUa.Runtime.csproj`.)
|
||
|
||
**Step 5: commit**
|
||
```bash
|
||
git commit -am "feat(historian-gateway): reshape ServerHistorianOptions to gateway form (Endpoint/ApiKey/Tls)
|
||
|
||
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Swap `AddServerHistorian` factory in Program.cs (READ CUTOVER)
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (the read cutover gate)
|
||
|
||
**Files:**
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs`
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs` (a Host-callable factory helper)
|
||
- Modify `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs`
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs`
|
||
|
||
**Step 1: failing test** — adapter construction + factory shape (no live gateway needed):
|
||
```csharp
|
||
public sealed class HistorianGatewayClientAdapterTests
|
||
{
|
||
[Fact]
|
||
public void Adapter_constructs_from_options_without_dialing()
|
||
{
|
||
// Constructing the channel must not perform network I/O (lazy connect).
|
||
var opts = new ServerHistorianOptions { Enabled = true, Endpoint = "https://localhost:5222", ApiKey = "histgw_x_y" };
|
||
using var adapter = GatewayHistorianClientAdapter.Create(opts, NullLoggerFactory.Instance);
|
||
Assert.NotNull(adapter);
|
||
}
|
||
|
||
[Fact]
|
||
public void Factory_builds_GatewayHistorianDataSource()
|
||
{
|
||
var opts = new ServerHistorianOptions { Enabled = true, Endpoint = "https://localhost:5222", ApiKey = "histgw_x_y" };
|
||
var ds = GatewayHistorian.CreateDataSource(opts, services: new ServiceCollection().BuildServiceProvider());
|
||
Assert.IsType<GatewayHistorianDataSource>(ds);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~HistorianGatewayClientAdapterTests"` → FAILS.
|
||
|
||
**Step 3: implement**
|
||
1. `HistorianGatewayClientAdapter : IHistorianGatewayClient` — wraps the package's `HistorianGatewayClient`. `Create(ServerHistorianOptions, ILoggerFactory)` builds the underlying client from `HistorianGatewayClientOptions` (`Endpoint`, `ApiKey`, `UseTls`, `AllowUntrustedServerCertificate`, `CaCertificatePath`, `CallTimeout`) — match the exact options surface the published Client package exposes (the sibling client plan). Each interface method forwards to the matching client wrapper (streaming → `IAsyncEnumerable`, unary → `Task`). Channel construction is **lazy** (no network I/O in the ctor). Map the client's typed exceptions through unchanged (the data source records them as failures).
|
||
2. `GatewayHistorian.CreateDataSource(ServerHistorianOptions, IServiceProvider)` static factory: `new GatewayHistorianDataSource(HistorianGatewayClientAdapter.Create(opts, sp.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance), sp.GetService<ILogger<GatewayHistorianDataSource>>() ?? NullLogger<…>.Instance)`.
|
||
3. In `Program.cs` (`hasDriver` block, the `AddServerHistorian` call ~lines 115-122): replace the `WonderwareHistorianClient` factory lambda with `(opts, sp) => GatewayHistorian.CreateDataSource(opts, sp)`. Update the `using` from `…Driver.Historian.Wonderware.Client` to `…Driver.Historian.Gateway` (the `AddAlarmHistorian` Wonderware lambda stays for now — T13 swaps it). Add the Host `<ProjectReference>` to the Gateway driver project in `ZB.MOM.WW.OtOpcUa.Host.csproj`.
|
||
|
||
**Step 4: run, expect PASS** — adapter tests pass; **`dotnet build ZB.MOM.WW.OtOpcUa.slnx` is clean** (Program.cs compiles against the reshaped options); the existing server-historian / node-manager HistoryRead tests stay green: `dotnet test --filter "FullyQualifiedName~HistoryRead|FullyQualifiedName~ServerHistorian"`.
|
||
|
||
**Step 5: commit**
|
||
```bash
|
||
git commit -am "feat(historian-gateway): read cutover — AddServerHistorian builds GatewayHistorianDataSource
|
||
|
||
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
|
||
```
|
||
|
||
> **This is the read cutover gate.** After this commit, OPC UA HistoryRead (raw/processed/at-time) for Galaxy + non-Galaxy tags flows through the gateway. Use case 1 is shippable here, before any write code.
|
||
|
||
---
|
||
|
||
### Task 11: `ReadEventsAsync` (alarm history) on the data source
|
||
**Classification:** small
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** none (extends T7 class; consumes T5)
|
||
|
||
**Files:**
|
||
- Modify `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs`
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayReadEventsTests.cs`
|
||
|
||
**Step 1: failing test:**
|
||
```csharp
|
||
public sealed class GatewayReadEventsTests
|
||
{
|
||
[Fact]
|
||
public async Task ReadEvents_maps_and_passes_source_filter()
|
||
{
|
||
var fake = new FakeHistorianGatewayClient {
|
||
Events = new[] { new HistorianEvent { Id="E1", SourceName="Pump1",
|
||
EventTime=Ts(2026,1,1,0,0,0), ReceivedTime=Ts(2026,1,1,0,0,0) } } };
|
||
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
||
var r = await ds.ReadEventsAsync("Pump1", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, maxEvents: 0, default);
|
||
Assert.Single(r.Events);
|
||
Assert.Equal("E1", r.Events[0].EventId);
|
||
Assert.Equal("Pump1", fake.LastReadEventsSourceName);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ReadEvents_truncation_sets_continuation_point()
|
||
{
|
||
var fake = new FakeHistorianGatewayClient {
|
||
Events = Enumerable.Range(0,5).Select(i => new HistorianEvent { Id=$"E{i}",
|
||
EventTime=Ts(2026,1,1,0,0,i), ReceivedTime=Ts(2026,1,1,0,0,i) }).ToArray() };
|
||
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
||
var r = await ds.ReadEventsAsync(null, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, maxEvents: 3, default);
|
||
Assert.Equal(3, r.Events.Count);
|
||
Assert.NotNull(r.ContinuationPoint); // cap truncated → non-null per Core.Abstractions-009
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~GatewayReadEventsTests"` → FAILS.
|
||
|
||
**Step 3: implement** — `ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, ct)`:
|
||
- Treat `maxEvents <= 0` as the backend-default sentinel (do not pass a cap; let the gateway apply its `EventReadMaxRows`). When `maxEvents > 0`, stop draining at `maxEvents` and set a **non-null `ContinuationPoint`** iff the source produced at least one more event (truncation signal, Core.Abstractions-009); otherwise null.
|
||
- Map via `EventMapper.ToHistoricalEvents`. Record health outcome.
|
||
- XML doc note: this path depends on the **gateway** being deployed with `RuntimeDb:EventReadsEnabled=true`; the **source-name server filter** is delivered by the gateway-client plan's SQL-ReadEvents enhancement — until it lands, `sourceName` is passed through but the gateway may return the full window and the adapter must **not** assume server-side filtering. (For v1 correctness, when `sourceName` is non-null, defensively filter the mapped events by `SourceName` client-side as well; remove once the server filter is confirmed.)
|
||
|
||
**Step 4: run, expect PASS.**
|
||
|
||
**Step 5: commit**
|
||
```bash
|
||
git commit -am "feat(historian-gateway): ReadEventsAsync alarm-history via gateway ReadEvents (+ truncation signal)
|
||
|
||
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: `GatewayAlarmHistorianWriter : IAlarmHistorianWriter`
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 14, Task 16 (independent files; all in Phase D)
|
||
|
||
**Files:**
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayAlarmHistorianWriter.cs`
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayAlarmHistorianWriterTests.cs`
|
||
|
||
**Step 1: failing test** — outcome mapping from gRPC status (uses the fake's per-call `SendEvent` behaviour):
|
||
```csharp
|
||
public sealed class GatewayAlarmHistorianWriterTests
|
||
{
|
||
private static AlarmHistorianEvent Evt(string id) => new(id,"Area/Pump","N","LimitAlarm",
|
||
AlarmSeverity.High,"Activated","m","u",null,new DateTime(2026,1,1,0,0,0,DateTimeKind.Utc));
|
||
|
||
[Fact]
|
||
public async Task All_acked_when_SendEvent_succeeds()
|
||
{
|
||
var fake = new FakeHistorianGatewayClient { SendEventResult = new WriteAck { Success = true } };
|
||
var w = new GatewayAlarmHistorianWriter(fake, NullLogger<GatewayAlarmHistorianWriter>.Instance);
|
||
var outcomes = await w.WriteBatchAsync(new[]{ Evt("A"), Evt("B") }, default);
|
||
Assert.All(outcomes, o => Assert.Equal(HistorianWriteOutcome.Ack, o));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Unavailable_is_RetryPlease()
|
||
{
|
||
var fake = new FakeHistorianGatewayClient { SendEventThrows = new RpcException(new Status(StatusCode.Unavailable, "down")) };
|
||
var w = new GatewayAlarmHistorianWriter(fake, NullLogger<GatewayAlarmHistorianWriter>.Instance);
|
||
var outcomes = await w.WriteBatchAsync(new[]{ Evt("A") }, default);
|
||
Assert.Equal(HistorianWriteOutcome.RetryPlease, outcomes[0]);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task InvalidArgument_is_PermanentFail()
|
||
{
|
||
var fake = new FakeHistorianGatewayClient { SendEventThrows = new RpcException(new Status(StatusCode.InvalidArgument, "malformed")) };
|
||
var w = new GatewayAlarmHistorianWriter(fake, NullLogger<GatewayAlarmHistorianWriter>.Instance);
|
||
var outcomes = await w.WriteBatchAsync(new[]{ Evt("A") }, default);
|
||
Assert.Equal(HistorianWriteOutcome.PermanentFail, outcomes[0]);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Empty_batch_returns_empty() =>
|
||
Assert.Empty(await new GatewayAlarmHistorianWriter(new FakeHistorianGatewayClient(),
|
||
NullLogger<GatewayAlarmHistorianWriter>.Instance).WriteBatchAsync(Array.Empty<AlarmHistorianEvent>(), default));
|
||
}
|
||
```
|
||
> If the published Client wraps `RpcException` in a typed hierarchy (`…UnavailableException`, `…AuthorizationException`), assert on those types instead — align with the client plan's exception design (§9). The fake should be configurable to throw whichever the real client surfaces.
|
||
|
||
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~GatewayAlarmHistorianWriterTests"` → FAILS.
|
||
|
||
**Step 3: implement** — `GatewayAlarmHistorianWriter : IAlarmHistorianWriter`: per event, `AlarmEventMapper.ToHistorianEvent` → `client.SendEventAsync`; map result to `HistorianWriteOutcome`:
|
||
- success ack ⇒ `Ack`.
|
||
- transient gRPC (`Unavailable`, `DeadlineExceeded`, `ResourceExhausted`, `Aborted`, `Internal`) or the client's `…UnavailableException` ⇒ `RetryPlease`.
|
||
- permanent gRPC (`InvalidArgument`, `FailedPrecondition`, `OutOfRange`, `Unimplemented`) ⇒ `PermanentFail` (so the drain worker dead-letters poison events instead of looping to the cap — mirrors the Wonderware `PerEventStatus==2` boundary).
|
||
- `Unauthenticated`/`PermissionDenied` ⇒ `RetryPlease` (a key fix re-enables the batch; do not dead-letter on an auth blip).
|
||
- Returns one outcome per event, same order. Empty batch ⇒ `[]`. Never throws out of `WriteBatchAsync` — it sits behind `SqliteStoreAndForwardSink`, which expects per-event outcomes.
|
||
|
||
**Step 4: run, expect PASS.**
|
||
|
||
**Step 5: commit**
|
||
```bash
|
||
git commit -am "feat(historian-gateway): GatewayAlarmHistorianWriter — SendEvent + gRPC->outcome mapping
|
||
|
||
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: Swap `AddAlarmHistorian` factory in Program.cs
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~3 min
|
||
**Parallelizable with:** none
|
||
|
||
**Files:**
|
||
- Modify `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs`
|
||
|
||
**Step 1: failing test** — reuse the existing Host integration/boot test (find it: `find tests -name "*Host*Tests*.cs" | head`); assert the resolved `IAlarmHistorianWriter` is the gateway writer when `AlarmHistorian:Enabled=true`. If no such test exists, add a minimal DI-resolution test in the Host test project that builds the service collection with the gateway writer factory and asserts the type. (Keep it offline — the writer ctor must not dial.)
|
||
|
||
**Step 2: run, expect FAIL** — run that test → FAILS (still wired to `WonderwareHistorianClient`).
|
||
|
||
**Step 3: implement** — in `Program.cs` `AddAlarmHistorian(...)` call (~lines 101-108): replace the `WonderwareHistorianClient` writer lambda with one that builds `new GatewayAlarmHistorianWriter(HistorianGatewayClientAdapter.Create(<gateway options>, sp.GetService<ILoggerFactory>()...), sp.GetService<ILogger<GatewayAlarmHistorianWriter>>()...)`. The alarm-write path must point at the **same gateway endpoint/key** as the read path — source the connection from `ServerHistorianOptions` (single gateway) rather than the Wonderware-shaped `AlarmHistorianOptions` host/port. Reuse a single `IHistorianGatewayClient`/adapter instance across read + alarm-write where practical (resolve a shared singleton in DI to avoid two channels); if simplest, register `IHistorianGatewayClient` as a singleton built from `ServerHistorianOptions` and have both factories consume it. Remove the now-dead `using …Wonderware.Client`.
|
||
|
||
**Step 4: run, expect PASS** — the test passes; `dotnet build ZB.MOM.WW.OtOpcUa.slnx` clean.
|
||
|
||
**Step 5: commit**
|
||
```bash
|
||
git commit -am "feat(historian-gateway): alarm-write cutover — AddAlarmHistorian drains to GatewayAlarmHistorianWriter
|
||
|
||
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: `IHistorianProvisioning` + `GatewayTagProvisioner` (EnsureTags)
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 12, Task 16
|
||
|
||
**Files:**
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianProvisioning.cs`
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayTagProvisioner.cs`
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayTagProvisionerTests.cs`
|
||
|
||
**Step 1: failing test:**
|
||
```csharp
|
||
public sealed class GatewayTagProvisionerTests
|
||
{
|
||
[Fact]
|
||
public async Task Ensures_numeric_tags_with_mapped_type()
|
||
{
|
||
var fake = new FakeHistorianGatewayClient { EnsureTagsResult = new TagOperationResults() };
|
||
var p = new GatewayTagProvisioner(fake, NullLogger<GatewayTagProvisioner>.Instance);
|
||
var reqs = new[] {
|
||
new HistorianTagProvisionRequest("Pump1.Temp", DriverDataType.Float32, "degC", "Temp"),
|
||
new HistorianTagProvisionRequest("Pump1.Run", DriverDataType.Boolean, null, null),
|
||
};
|
||
var result = await p.EnsureTagsAsync(reqs, default);
|
||
Assert.Equal(2, fake.LastEnsureDefinitions.Count);
|
||
Assert.Equal(HistorianDataType.Float, fake.LastEnsureDefinitions[0].DataType);
|
||
Assert.Equal(HistorianDataType.Int1, fake.LastEnsureDefinitions[1].DataType);
|
||
Assert.Equal(2, result.Requested);
|
||
Assert.Equal(0, result.Skipped);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Deferred_types_are_skipped_not_sent()
|
||
{
|
||
var fake = new FakeHistorianGatewayClient { EnsureTagsResult = new TagOperationResults() };
|
||
var p = new GatewayTagProvisioner(fake, NullLogger<GatewayTagProvisioner>.Instance);
|
||
var result = await p.EnsureTagsAsync(new[] {
|
||
new HistorianTagProvisionRequest("Pump1.Name", DriverDataType.String, null, null) }, default);
|
||
Assert.Empty(fake.LastEnsureDefinitions); // String is deferred → never sent
|
||
Assert.Equal(1, result.Skipped);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Gateway_failure_is_swallowed_and_counted_not_thrown()
|
||
{
|
||
var fake = new FakeHistorianGatewayClient { EnsureTagsThrows = new Exception("boom") };
|
||
var p = new GatewayTagProvisioner(fake, NullLogger<GatewayTagProvisioner>.Instance);
|
||
var result = await p.EnsureTagsAsync(new[] {
|
||
new HistorianTagProvisionRequest("Pump1.Temp", DriverDataType.Float32, null, null) }, default);
|
||
Assert.Equal(1, result.Failed); // non-blocking: no throw
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~GatewayTagProvisionerTests"` → FAILS.
|
||
|
||
**Step 3: implement**
|
||
- `IHistorianProvisioning` (Core.Abstractions, alongside `IHistorianDataSource`): `Task<HistorianProvisionResult> EnsureTagsAsync(IReadOnlyList<HistorianTagProvisionRequest> requests, CancellationToken ct)`. Define `HistorianTagProvisionRequest(string TagName, DriverDataType DataType, string? EngineeringUnit, string? Description)` and `HistorianProvisionResult(int Requested, int Ensured, int Skipped, int Failed)` records here too. Add a `NullHistorianProvisioning` no-op (returns all-zero) so the Applier has a safe default.
|
||
- `GatewayTagProvisioner : IHistorianProvisioning` (Gateway driver): for each request, gate on `HistorianTypeMapper.IsHistorizable(DataType)` — non-historizable ⇒ count `Skipped`, log Debug, never build a definition; otherwise build `HistorianTagDefinition { TagName, DataType = HistorianTypeMapper.ToHistorianDataType(...), EngineeringUnit, Description }`. Batch all historizable definitions into one `client.EnsureTagsAsync` call. **Non-blocking semantics**: wrap the call in try/catch — any exception ⇒ count the batch as `Failed`, log a Warning (no tag values), return. Returns the result record. Never throws.
|
||
|
||
**Step 4: run, expect PASS.**
|
||
|
||
**Step 5: commit**
|
||
```bash
|
||
git commit -am "feat(historian-gateway): IHistorianProvisioning + GatewayTagProvisioner (EnsureTags, non-blocking)
|
||
|
||
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 15: Hook provisioning into `AddressSpaceApplier.Apply()`
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none
|
||
|
||
**Files:**
|
||
- Modify `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj` (add `Core.Abstractions` ProjectReference)
|
||
- Modify `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs`
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierProvisioningTests.cs` (confirm the OpcUaServer test project path with `find tests -name "*OpcUaServer.Tests.csproj"`)
|
||
|
||
**Step 1: failing test** — synthetic plan with mixed historized/non-historized tags + a capturing `IHistorianProvisioning`:
|
||
```csharp
|
||
public sealed class AddressSpaceApplierProvisioningTests
|
||
{
|
||
private sealed class CapturingProvisioner : IHistorianProvisioning
|
||
{
|
||
public List<HistorianTagProvisionRequest> Seen = new();
|
||
public bool Throw;
|
||
public Task<HistorianProvisionResult> EnsureTagsAsync(IReadOnlyList<HistorianTagProvisionRequest> r, CancellationToken ct)
|
||
{
|
||
if (Throw) throw new Exception("boom");
|
||
Seen.AddRange(r);
|
||
return Task.FromResult(new HistorianProvisionResult(r.Count, r.Count, 0, 0));
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void Apply_provisions_only_historized_added_tags()
|
||
{
|
||
var prov = new CapturingProvisioner();
|
||
var applier = new AddressSpaceApplier(NullSink, NullLogger, prov);
|
||
var plan = PlanWith(
|
||
Tag("Pump1.Temp", historized:true, historianName:"Pump1.Temp", type:"Float32"),
|
||
Tag("Pump1.Run", historized:false, historianName:null, type:"Boolean"));
|
||
applier.Apply(plan);
|
||
Assert.Single(prov.Seen);
|
||
Assert.Equal("Pump1.Temp", prov.Seen[0].TagName); // resolved historian name
|
||
}
|
||
|
||
[Fact]
|
||
public void Provisioner_throw_does_not_block_publish()
|
||
{
|
||
var applier = new AddressSpaceApplier(NullSink, NullLogger, new CapturingProvisioner { Throw = true });
|
||
var outcome = applier.Apply(PlanWith(Tag("Pump1.Temp", historized:true, historianName:"Pump1.Temp", type:"Float32")));
|
||
Assert.True(outcome.RebuildCalled); // address-space work still completed
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~AddressSpaceApplierProvisioningTests"` → FAILS (ctor arity / hook absent).
|
||
|
||
**Step 3: implement**
|
||
- Add `<ProjectReference>` from `ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj` to `Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions` (it is a leaf abstractions project — OpcUaServer already references Commons; no cycle).
|
||
- Add an `IHistorianProvisioning` ctor param to `AddressSpaceApplier` **defaulting to `NullHistorianProvisioning.Instance`** (preserves every existing call site — including the one in `ServiceCollectionExtensions.WithOtOpcUaRuntimeActors` that constructs the applier).
|
||
- In `Apply(plan)`, after the address-space work (rebuild/surgical passes) completes, iterate `plan.AddedEquipmentTags.Where(t => t.IsHistorized)`:
|
||
- resolve the historian name exactly as the materialiser does: `string.IsNullOrWhiteSpace(t.HistorianTagname) ? t.FullName : t.HistorianTagname`.
|
||
- parse `t.DataType` (string) → `DriverDataType` via `Enum.TryParse`; an unparseable type ⇒ skip + log Debug.
|
||
- build `HistorianTagProvisionRequest(historianName, dataType, EngineeringUnit: null, Description: t.Name)`.
|
||
- Fire-and-forget the provisioning **off the apply path** so it NEVER blocks the publish: call `provisioner.EnsureTagsAsync(requests, CancellationToken.None)` and observe the task on a continuation that logs `result.Failed`/`Skipped` (and swallows any escaped exception). The synchronous `Apply` returns its normal `AddressSpaceApplyOutcome` regardless. Wrap the whole hook in try/catch so a hook fault cannot break a deploy.
|
||
|
||
> High-risk: this runs on the OPC UA publish actor's pinned thread. Keep the hook's synchronous portion to building the request list only; the gateway round-trip must be fully detached (do not `.Wait()`/`.Result`). Tests assert publish completes even when the provisioner throws synchronously.
|
||
|
||
**Step 4: run, expect PASS** — provisioning tests pass; the existing `AddressSpaceApplier` test suite stays green (`dotnet test --filter "FullyQualifiedName~AddressSpaceApplier"`); `dotnet build` clean.
|
||
|
||
**Step 5: commit**
|
||
```bash
|
||
git commit -am "feat(historian-gateway): EnsureTags provisioning hook in AddressSpaceApplier (non-blocking)
|
||
|
||
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 16: FasterLog outbox store for the recorder
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 12, Task 14
|
||
|
||
**Files:**
|
||
- Modify `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj` (add `Microsoft.FASTER.Core` 2.6.5)
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Recorder/HistorizationOutboxEntry.cs`
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Recorder/IHistorizationOutbox.cs`
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Recorder/FasterLogHistorizationOutbox.cs`
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Recorder/FasterLogHistorizationOutboxTests.cs`
|
||
|
||
**Step 1: failing test** — append/peek/remove, restart durability, full-drop:
|
||
```csharp
|
||
public sealed class FasterLogHistorizationOutboxTests
|
||
{
|
||
private static HistorizationOutboxEntry E(string tag, double v) =>
|
||
new(Guid.NewGuid(), tag, v, 192, new DateTime(2026,1,1,0,0,0,DateTimeKind.Utc));
|
||
|
||
[Fact]
|
||
public async Task Append_then_peek_returns_fifo()
|
||
{
|
||
var dir = NewTempDir();
|
||
using var o = new FasterLogHistorizationOutbox(dir, StoreForwardCommitMode.PerEntry);
|
||
await o.AppendAsync(E("A",1), default);
|
||
await o.AppendAsync(E("B",2), default);
|
||
var batch = await o.PeekBatchAsync(10, default);
|
||
Assert.Equal(new[]{"A","B"}, batch.Select(b => b.Tag));
|
||
Assert.Equal(2, await o.CountAsync(default));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Remove_truncates_and_survives_restart()
|
||
{
|
||
var dir = NewTempDir();
|
||
Guid keep;
|
||
{
|
||
using var o = new FasterLogHistorizationOutbox(dir, StoreForwardCommitMode.PerEntry);
|
||
var a = E("A",1); var b = E("B",2); keep = b.Id;
|
||
await o.AppendAsync(a, default); await o.AppendAsync(b, default);
|
||
await o.PeekBatchAsync(10, default);
|
||
await o.RemoveAsync(a.Id, default); // ack A
|
||
}
|
||
using var reopened = new FasterLogHistorizationOutbox(dir, StoreForwardCommitMode.PerEntry);
|
||
Assert.Equal(1, await reopened.CountAsync(default)); // only B survives
|
||
var batch = await reopened.PeekBatchAsync(10, default);
|
||
Assert.Equal(keep, batch[0].Id);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Capacity_full_drops_oldest_and_counts()
|
||
{
|
||
var dir = NewTempDir();
|
||
using var o = new FasterLogHistorizationOutbox(dir, StoreForwardCommitMode.PerEntry, capacity: 2);
|
||
await o.AppendAsync(E("A",1), default);
|
||
await o.AppendAsync(E("B",2), default);
|
||
await o.AppendAsync(E("C",3), default); // overflow → drop oldest (A)
|
||
Assert.Equal(2, await o.CountAsync(default));
|
||
Assert.Equal(1, o.DroppedCount);
|
||
var tags = (await o.PeekBatchAsync(10, default)).Select(b => b.Tag).ToArray();
|
||
Assert.DoesNotContain("A", tags);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~FasterLogHistorizationOutboxTests"` → FAILS.
|
||
|
||
**Step 3: implement** — **mirror the gateway's `FasterLogOutboxStore`** (read it first):
|
||
- `HistorizationOutboxEntry(Guid Id, string Tag, double NumericValue, ushort Quality, DateTime TimestampUtc)` + a compact binary serializer (BinaryWriter or the same approach the gateway's `OutboxEntrySerializer` uses).
|
||
- `IHistorizationOutbox` (Append/PeekBatch/Remove/Count, `DroppedCount`, `IDisposable`).
|
||
- `FasterLogHistorizationOutbox`: `ManagedLocalStorageDevice` under `<dir>/hlog.log`; `FasterLog`; PerEntry commits before `AppendAsync` returns, Periodic drives a `PeriodicTimer` commit loop; `RemoveAsync` ⇒ `TruncateUntil(window[id])` + `CommitAsync`; `RecoverState()` in the ctor rebuilds count + head from the committed log (restart durability). **Add the capacity/drop-oldest behavior the gateway store lacks**: a `capacity` ctor arg; when an append would exceed it, advance the head past the oldest entry (truncate one) and increment `DroppedCount` (the recorder surfaces it as a metric in T17/T18). Use `StoreForwardCommitMode` from the gateway's Configuration namespace **or** define a local `enum HistorizationCommitMode { PerEntry, Periodic }` to avoid a cross-package coupling — prefer the local enum (the gateway's type is internal to its Server project).
|
||
|
||
> High-risk: this is the durable boundary. The restart-durability + truncate-correctness tests are load-bearing; do not skip them.
|
||
|
||
**Step 4: run, expect PASS.**
|
||
|
||
**Step 5: commit**
|
||
```bash
|
||
git commit -am "feat(historian-gateway): FasterLog historization outbox (PerEntry/Periodic, drop-oldest)
|
||
|
||
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 17: `ContinuousHistorizationRecorder` actor
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (consumes T16 + the client)
|
||
|
||
**Files:**
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs`
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs`
|
||
|
||
> The recorder lives in **Runtime** (it references `DependencyMuxActor` + `DriverInstanceActor.AttributeValuePublished`, both in Runtime), and consumes `IHistorianGatewayClient` + `IHistorizationOutbox` via abstractions so Runtime does not take a hard dependency on the Gateway driver project. Define the two seams the recorder needs (`IHistorianGatewayClient` write subset, `IHistorizationOutbox`) such that Runtime can reference them — either move `IHistorizationOutbox` + a `IHistorianValueWriter` (just `WriteLiveValuesAsync`) into `Core.Abstractions/Historian`, and have the Gateway driver implement them. **Decision:** introduce `IHistorianValueWriter` (single method `Task<bool> WriteLiveValuesAsync(string tag, IReadOnlyList<(DateTime? ts, double value, ushort quality)> values, CancellationToken ct)`) and `IHistorizationOutbox` in `Core.Abstractions/Historian`; `GatewayHistorianValueWriter` (Gateway driver) adapts `IHistorianGatewayClient.WriteLiveValuesAsync`. This keeps Runtime free of the gRPC package.
|
||
|
||
**Files (revised to honour the layering decision):**
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianValueWriter.cs` (+ move `IHistorizationOutbox` + `HistorizationOutboxEntry` here; the FasterLog impl from T16 stays in the Gateway driver project implementing this interface)
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Recorder/GatewayHistorianValueWriter.cs`
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationRecorder.cs`
|
||
- Create the recorder test file above.
|
||
|
||
**Step 1: failing test** — Akka TestKit with fake mux + fake writer + real/fake outbox:
|
||
```csharp
|
||
public sealed class ContinuousHistorizationRecorderTests : TestKit
|
||
{
|
||
[Fact]
|
||
public void Registers_interest_for_historized_refs_on_start()
|
||
{
|
||
var mux = CreateTestProbe();
|
||
var writer = new FakeValueWriter();
|
||
var outbox = new InMemoryOutbox();
|
||
Sys.ActorOf(ContinuousHistorizationRecorder.Props(mux.Ref, writer, outbox,
|
||
historizedRefs: new[]{ "Pump1.Temp" }));
|
||
var reg = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
|
||
Assert.Contains("Pump1.Temp", reg.TagRefs);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task AttributeValuePublished_appends_to_outbox_then_drains_to_writer()
|
||
{
|
||
var mux = CreateTestProbe();
|
||
var writer = new FakeValueWriter { Succeed = true };
|
||
var outbox = new InMemoryOutbox();
|
||
var rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(mux.Ref, writer, outbox, new[]{ "Pump1.Temp" }));
|
||
rec.Tell(new DriverInstanceActor.AttributeValuePublished("drv","Pump1.Temp", 42.0, OpcUaQuality.Good, DateTime.UtcNow));
|
||
await AwaitAssertAsync(() => Assert.Contains(writer.Written, w => w.Tag=="Pump1.Temp" && w.Value==42.0));
|
||
await AwaitAssertAsync(async () => Assert.Equal(0, await outbox.CountAsync(default))); // acked → truncated
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Writer_failure_keeps_entry_for_retry()
|
||
{
|
||
var mux = CreateTestProbe();
|
||
var writer = new FakeValueWriter { Succeed = false };
|
||
var outbox = new InMemoryOutbox();
|
||
var rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(mux.Ref, writer, outbox, new[]{ "Pump1.Temp" }));
|
||
rec.Tell(new DriverInstanceActor.AttributeValuePublished("drv","Pump1.Temp", 7.0, OpcUaQuality.Good, DateTime.UtcNow));
|
||
await AwaitAssertAsync(async () => Assert.Equal(1, await outbox.CountAsync(default))); // not acked → retained
|
||
}
|
||
|
||
[Fact]
|
||
public void Non_numeric_value_is_dropped_with_metric()
|
||
{
|
||
var mux = CreateTestProbe(); var writer = new FakeValueWriter(); var outbox = new InMemoryOutbox();
|
||
var rec = Sys.ActorOf(ContinuousHistorizationRecorder.Props(mux.Ref, writer, outbox, new[]{ "Pump1.Name" }));
|
||
rec.Tell(new DriverInstanceActor.AttributeValuePublished("drv","Pump1.Name", "text", OpcUaQuality.Good, DateTime.UtcNow));
|
||
// string value: SQL analog write path can't carry it → dropped, not appended
|
||
AwaitAssert(() => Assert.Empty(writer.Written));
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~ContinuousHistorizationRecorderTests"` → FAILS.
|
||
|
||
**Step 3: implement** — `ContinuousHistorizationRecorder : ReceiveActor`:
|
||
- `Props(IActorRef dependencyMux, IHistorianValueWriter writer, IHistorizationOutbox outbox, IReadOnlyList<string> historizedRefs, ...options)`.
|
||
- `PreStart`: `dependencyMux.Tell(new DependencyMuxActor.RegisterInterest(historizedRefs, Self))`. (The mux fans `AttributeValuePublished` for those refs back as `DependencyValueChanged`; **or** wire the recorder to receive `AttributeValuePublished` directly from `DriverHostActor`'s forward — match the actual fan-out. Per the mux contract, register interest and handle `VirtualTagActor.DependencyValueChanged`; if the recorder must see ALL historized refs regardless of vtag interest, instead have `DriverHostActor` forward `AttributeValuePublished` to the recorder. **Choose the `RegisterInterest`→`DependencyValueChanged` path** to reuse the existing mux without touching `DriverHostActor`; the test asserts the register message.)
|
||
- On a value-change message for a historized ref: coerce the value to numeric (`double`); a non-numeric/non-coercible value (string/null) is **dropped + metered** (the SQL analog `WriteLiveValues` path is numeric-only — design §6/§7, §11 risk 2). Append a `HistorizationOutboxEntry` to the outbox (the durable boundary). On outbox-full, the store drops oldest + increments `DroppedCount`.
|
||
- A background drain (self-scheduled tick via `Context.System.Scheduler` / `Timers`): `PeekBatchAsync(batchSize)` → group by tag → `writer.WriteLiveValuesAsync(tag, values)`; on success `RemoveAsync` each acked id; on failure leave queued and **back off** (exponential, capped) before the next tick. Never let a drain exception kill the actor (supervised try/catch; log Warning, back off).
|
||
- Surface counters (queued depth, dropped, last drain success) for T18's meter/health.
|
||
|
||
> High-risk: actor + durable recorder + data contract. The append-before-ack ordering, the numeric-only gate, and the failure-retains-entry behavior are the load-bearing invariants. Use `Akka.TestKit.Xunit2`.
|
||
|
||
**Step 4: run, expect PASS.**
|
||
|
||
**Step 5: commit**
|
||
```bash
|
||
git commit -am "feat(historian-gateway): ContinuousHistorizationRecorder actor (outbox->WriteLiveValues, backoff)
|
||
|
||
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 18: Wire the recorder into DI + hosted lifecycle
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none
|
||
|
||
**Files:**
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ContinuousHistorizationOptions.cs`
|
||
- Modify `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs` (spawn the recorder in `WithOtOpcUaRuntimeActors`, gated on options)
|
||
- Modify `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` (bind options + register `IHistorianValueWriter` + `IHistorizationOutbox` from the gateway, gated)
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationOptionsTests.cs`
|
||
|
||
**Step 1: failing test** — options validation + actor-spawn gating:
|
||
```csharp
|
||
public sealed class ContinuousHistorizationOptionsTests
|
||
{
|
||
[Fact] public void Disabled_no_warnings() => Assert.Empty(new ContinuousHistorizationOptions{Enabled=false}.Validate());
|
||
[Fact] public void Enabled_requires_outbox_path()
|
||
=> Assert.Contains(new ContinuousHistorizationOptions{Enabled=true, OutboxPath=""}.Validate(), m => m.Contains("OutboxPath"));
|
||
[Fact] public void Periodic_requires_positive_interval()
|
||
=> Assert.Contains(new ContinuousHistorizationOptions{Enabled=true, OutboxPath="x", CommitMode="Periodic", CommitIntervalMs=0}.Validate(), m => m.Contains("CommitIntervalMs"));
|
||
}
|
||
```
|
||
Plus a Runtime spawn test (mirror existing `WithOtOpcUaRuntimeActors` tests): when `ContinuousHistorization:Enabled=false`, the recorder actor is NOT spawned; when enabled with a fake writer/outbox registered, it IS (`registry.Get<ContinuousHistorizationRecorderKey>()` resolves).
|
||
|
||
**Step 2: run, expect FAIL** — `dotnet test --filter "FullyQualifiedName~ContinuousHistorizationOptions"` → FAILS.
|
||
|
||
**Step 3: implement**
|
||
- `ContinuousHistorizationOptions` (`SectionName="ContinuousHistorization"`): `Enabled`, `OutboxPath` (directory; required when enabled — production absolute path), `CommitMode` (`PerEntry`/`Periodic`), `CommitIntervalMs`, `DrainBatchSize` (default 64), `DrainIntervalSeconds`, `Capacity`, `RetryBackoff`. `Validate()` warns on the gated cases above.
|
||
- In `WithOtOpcUaRuntimeActors`: resolve `IHistorianValueWriter` + `IHistorizationOutbox` (+ the historized-ref set — source it from the deployed composition; for v1, the recorder can register interest dynamically as tags deploy, but the minimal wiring registers the actor and lets a later `SetHistorizedRefs` message feed it. **Keep T18 scope to: bind options, build the outbox from `OutboxPath`, spawn the recorder when enabled, register its key.**). Gate the spawn on `ContinuousHistorizationOptions.Enabled`.
|
||
- In `Program.cs` (`hasDriver` block): bind+validate the options; when enabled, register `IHistorizationOutbox` ⇒ `FasterLogHistorizationOutbox(opts.OutboxPath, …)` and `IHistorianValueWriter` ⇒ `GatewayHistorianValueWriter` over the shared `IHistorianGatewayClient` (the singleton from T13). Add a meter/observable-gauge for outbox depth + dropped count (mirror the existing observability registration), and feed the recorder's health into `/healthz` if a historian health hook exists.
|
||
|
||
**Step 4: run, expect PASS** — option + spawn tests pass; `dotnet build ZB.MOM.WW.OtOpcUa.slnx` clean; full offline suite green: `dotnet test ZB.MOM.WW.OtOpcUa.slnx`.
|
||
|
||
**Step 5: commit**
|
||
```bash
|
||
git commit -am "feat(historian-gateway): wire ContinuousHistorizationRecorder into DI + hosted lifecycle + meters
|
||
|
||
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 19: Retire the Wonderware historian projects
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (must be last code task)
|
||
|
||
**Files (remove from solution + delete):**
|
||
- `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware`
|
||
- `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client`
|
||
- `/Users/dohertj2/Desktop/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts`
|
||
- `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests`
|
||
- `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests`
|
||
- Modify `/Users/dohertj2/Desktop/OtOpcUa/ZB.MOM.WW.OtOpcUa.slnx`
|
||
- Grep-and-remove any remaining `using …Wonderware…` / `ProjectReference …Wonderware…` (notably `Host.csproj`).
|
||
|
||
> Leave `/Users/dohertj2/Desktop/OtOpcUa/code-reviews/Driver.Historian.Wonderware*` untouched — those are review artifacts, not projects.
|
||
|
||
**Step 1: failing test** — a guard test asserting the Wonderware types are gone (compiles only after removal):
|
||
```csharp
|
||
public sealed class WonderwareRetirementTests
|
||
{
|
||
[Fact]
|
||
public void No_Wonderware_historian_assembly_is_loaded()
|
||
{
|
||
var loaded = AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetName().Name);
|
||
Assert.DoesNotContain(loaded, n => n is not null && n.Contains("Wonderware", StringComparison.OrdinalIgnoreCase));
|
||
}
|
||
}
|
||
```
|
||
(Place in the Gateway driver test project. Before removal this passes trivially only if nothing loaded it — instead make the gate the **build**: after deleting, `dotnet build` must still be clean with no dangling references. The test documents intent.)
|
||
|
||
**Step 2: run, expect FAIL** — first run `dotnet build ZB.MOM.WW.OtOpcUa.slnx` BEFORE deletion to confirm it is currently green with both backends, then delete; the meaningful failure is a **dangling reference** build break if any `using`/`ProjectReference` survives.
|
||
|
||
**Step 3: implement**
|
||
1. `dotnet sln ZB.MOM.WW.OtOpcUa.slnx remove <each of the 5 project paths>`.
|
||
2. `git rm -r <each of the 5 directories>`.
|
||
3. `grep -rn "Wonderware" src/ tests/ --include=*.cs --include=*.csproj` and remove every remaining reference (Host.csproj `ProjectReference`, any stray `using`). `AlarmHistorianOptions` Wonderware-shaped fields (Host/Port/SharedSecret) — if now unused by the gateway alarm-write wiring (T13 sources connection from `ServerHistorian`), prune them; otherwise leave and note in T21.
|
||
|
||
**Step 4: run, expect PASS** — `dotnet build ZB.MOM.WW.OtOpcUa.slnx` clean (0 warnings, no dangling refs); full suite green: `dotnet test ZB.MOM.WW.OtOpcUa.slnx`.
|
||
|
||
**Step 5: commit**
|
||
```bash
|
||
git rm -r src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests
|
||
git commit -am "refactor(historian-gateway): retire Wonderware historian projects (gateway is sole backend)
|
||
|
||
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 20: Env-gated live validation vs `wonder-sql-vd03`
|
||
**Classification:** small
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none
|
||
|
||
**Files:**
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveIntegrationTests.cs`
|
||
- Create `/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveFixture.cs`
|
||
|
||
**Step 1: failing test** — `[Trait("Category","LiveIntegration")]` tests that **skip** when env vars are absent:
|
||
```csharp
|
||
public sealed class GatewayLiveIntegrationTests
|
||
{
|
||
// Reads HISTGW_GATEWAY_ENDPOINT + HISTGW_GATEWAY_APIKEY + HISTGW_TEST_TAG (Galaxy tag),
|
||
// HISTGW_WRITE_SANDBOX_TAG (HistGW.LiveTest.*), HISTGW_ALARM_SOURCE. Skips if endpoint/key absent.
|
||
[SkippableFact, Trait("Category","LiveIntegration")]
|
||
public async Task Galaxy_tag_read_round_trip() { Skip.If(Fixture.NotConfigured); /* ReadRaw last 1h, assert >=0 samples, no throw */ }
|
||
|
||
[SkippableFact, Trait("Category","LiveIntegration")]
|
||
public async Task Write_then_read_on_sandbox_tag() { /* EnsureTags(Float) → WriteLiveValues → ReadRaw → sample present */ }
|
||
|
||
[SkippableFact, Trait("Category","LiveIntegration")]
|
||
public async Task Alarm_SendEvent_then_ReadEvents() { /* SendEvent → ReadEvents(source) → event present (needs RuntimeDb:EventReadsEnabled) */ }
|
||
}
|
||
```
|
||
|
||
**Step 2: run, expect FAIL/SKIP** — `dotnet test --filter "Category=LiveIntegration"` with no env → all SKIP (green). With env set + VPN up → FAIL until the live path works.
|
||
|
||
**Step 3: implement** — the fixture builds a real `HistorianGatewayClientAdapter` from env, the three round-trips exercise the read/write/alarm paths against the live gateway → `wonder-sql-vd03`. Mirror the HistorianGateway repo's `GatewayIntegrationFixture` env-gating convention (skip+log when unset). **VPN-gated**: `wonder-sql-vd03` is reachable only on VPN — if the endpoint is configured but unreachable, prompt the user to connect VPN (do not hang).
|
||
|
||
**Step 4: run, expect PASS** — offline: all SKIP. On VPN with env + the gateway running `RuntimeDb:Enabled=true` + `RuntimeDb:EventReadsEnabled=true`: the three round-trips PASS (this is the real-world validation gate before T19's retirement is trusted).
|
||
|
||
**Step 5: commit**
|
||
```bash
|
||
git commit -am "test(historian-gateway): env-gated live validation vs wonder-sql-vd03 (read/write/alarm round-trips)
|
||
|
||
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 21: Documentation
|
||
**Classification:** small
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none
|
||
|
||
**Files:**
|
||
- Modify `/Users/dohertj2/Desktop/OtOpcUa/CLAUDE.md`
|
||
- Modify `/Users/dohertj2/Desktop/OtOpcUa/README.md` (if present)
|
||
- Modify the relevant `appsettings*.json` (the `ServerHistorian` + new `ContinuousHistorization` blocks) under `src/Server/ZB.MOM.WW.OtOpcUa.Host/`
|
||
|
||
**Step 1: failing test** — none (docs). Verification is review + a `grep` guard: the docs must not reference the retired Wonderware backend as current, and must document the new config keys.
|
||
|
||
**Step 2: run, expect FAIL** — `grep -n "Wonderware" CLAUDE.md` shows stale references (the backend section still describes the sidecar).
|
||
|
||
**Step 3: implement**
|
||
- `CLAUDE.md`: historian backend is now **HistorianGateway** (gRPC client package); document the `ServerHistorian` keys (`Endpoint`/`ApiKey`/`UseTls`/`AllowUntrustedServerCertificate`/`CaCertificatePath`/`CallTimeout`), the `ContinuousHistorization` section, the `IHistorianProvisioning` EnsureTags hook, the alarm SendEvent path + `ReadEvents` dependency on gateway `RuntimeDb:EventReadsEnabled=true`, and that the Wonderware projects were retired. Note the gateway-side prerequisites (RuntimeDb flags + API-key scopes).
|
||
- `appsettings`: a commented `ServerHistorian` example (blank `ApiKey` — env-supplied) + a `ContinuousHistorization` example (disabled by default, absolute `OutboxPath` note for production).
|
||
- Migration note: deployments must rename their old `ServerHistorian` keys (`Host`/`Port`/`SharedSecret`/`ServerCertThumbprint` → `Endpoint`/`ApiKey`/`UseTls`/`CaCertificatePath`) and supply `ServerHistorian__ApiKey` via env.
|
||
|
||
**Step 4: run, expect PASS** — `grep -n "Wonderware" CLAUDE.md` returns only retirement-history mentions; the new keys are documented.
|
||
|
||
**Step 5: commit**
|
||
```bash
|
||
git commit -am "docs(historian-gateway): document gateway backend, config keys, EnsureTags hook, Wonderware retirement
|
||
|
||
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii"
|
||
```
|
||
|
||
---
|
||
|
||
## Execution order & parallelism
|
||
|
||
- **Phase A (T1)** runs first — every later task depends on the project + consumed package.
|
||
- **Phase B mappers (T2–T6)** are all pure/no-I/O and fully **parallelizable** with each other once T1 lands.
|
||
- **Phase C (T7→T8→T9→T10→T11)** follows B. T7 consumes T2+T4; T9 (options reshape) can proceed in parallel with B/T7 (different area). **T10 is the read cutover gate** — it depends on T7 (data source) + T9 (options) and makes use case 1 shippable on its own. T8 and T11 extend T7's class (sequential with it).
|
||
- **Phase D (T12–T18)** follows the read cutover (T10). T12 (alarm writer), T14 (provisioner), T16 (outbox) are mutually **parallelizable**; T13 depends on T12; T15 depends on T14; T17 depends on T16 (+ client write seam); T18 depends on T17.
|
||
- **Phase E:** **T20 (live validation)** runs after the gateway path is fully wired (read cutover + alarm + recorder). **T19 (Wonderware retirement) lands LAST — only after D is green AND T20's live round-trips pass** (do not delete the proven-out backend until the replacement is live-validated). T21 (docs) follows the retirement so it documents the final state.
|
||
- The whole live-validation phase is gated on VPN reachability of `wonder-sql-vd03` and on the gateway being deployed with `RuntimeDb:Enabled=true` + `RuntimeDb:EventReadsEnabled=true`.
|
||
|
||
## Live/VPN + verify-live risks (design §11)
|
||
|
||
Settle these during T10/T11/T17/T20 against `wonder-sql-vd03` (prompt the user to connect VPN if unreachable):
|
||
|
||
1. **Galaxy-tag → historian-tag identity.** Does OtOpcUa's resolved `historianTagname` (`FullName` or the `HistorianTagname` override, typically `tag_name.Attribute`) match the AVEVA historian's stored tag name? Confirm early in T20's read round-trip — a mismatch surfaces as empty reads, not errors.
|
||
2. **UInt16 / String / DateTime write gaps.** Continuous historization is **numeric-analog only** in v1. `UInt16→UInt4` is a documented fallback; `String`/`DateTime`/`Reference` are deferred and **throw `NotSupported` (provisioning skips them, the recorder drops non-numeric values + meters)** — never silent. T3/T14/T17 encode this; T21 documents it.
|
||
3. **Alarm-history reads depend on the gateway's SQL event path.** `ReadEventsAsync` requires the gateway deployed with `RuntimeDb:EventReadsEnabled=true`; the **source-name server filter** is delivered by the gateway-client plan (§5.3) — until it lands, T11 filters client-side and must not assume server filtering.
|
||
4. **`WriteLiveValues` requires gateway `RuntimeDb:Enabled=true` AND an `EnsureTags`-provisioned tag.** The provisioning hook (T15) must have run for a tag before the recorder's writes (T17) land — provisioning is fire-and-forget, so the first few values for a brand-new historized tag may be rejected until `EnsureTags` completes; the outbox retains + retries them (no loss). Validate the ordering in T20's write→read round-trip.
|
||
5. **`received_time` UTC semantics.** On the SQL event/value paths, inherit whatever the gateway's `feat/sql-readevents` work establishes for local-vs-UTC and `EventTimeUTCOffsetMins`. Stamp all OtOpcUa-side timestamps UTC (`DateTimeKind.Utc`) and assert kind in the mappers (T4/T5/T6).
|