c95824a65d
Full read-only SDK (src/AVEVA.Historian.Client) implementing the CLAUDE.md required
surface against AVEVA Historian's binary WCF protocol — no native AVEVA runtime
dependency. All operations live-verified against a local Historian:
- ProbeAsync, ReadRawAsync, ReadAggregateAsync, ReadAtTimeAsync, ReadEventsAsync
- BrowseTagNamesAsync, GetTagMetadataAsync (17 native data-type codes mapped)
- GetConnectionStatusAsync, GetStoreForwardStatusAsync, GetSystemParameterAsync
- 108/108 unit + integration tests pass
Includes the reverse-engineering toolkit (tools/AVEVA.Historian.ReverseEngineering)
used to decode the protocol: WCF probes, IL inspection via dnlib, and IL-rewrite
instrumentation (instrument-wcf-{write,read}message etc.) plus the .NET Framework
trace harness (tools/AVEVA.Historian.NativeTraceHarness) for parity testing.
Sanitized handoff evidence under docs/reverse-engineering/. Native AVEVA binaries
(current/, aveva-install-x64/, aveva-install-x86/) are gitignored — fetch separately
from the AVEVA installer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
255 lines
12 KiB
Markdown
255 lines
12 KiB
Markdown
# Building a fully-managed .NET 10 AVEVA Historian SDK
|
||
|
||
Replacement plan for the `aahClientManaged` SDK — the goal is to drop the
|
||
.NET 4.8 / native-`aahClient.dll` sidecar in `OtOpcUa.Driver.Historian.Wonderware`
|
||
and call the Historian directly from the .NET 10 OPC UA server process.
|
||
|
||
## Why this is hard
|
||
|
||
`aahClientManaged.dll` (this folder, `current\`) is a thin managed wrapper around
|
||
`aahClient.dll`, which is **native C++** that speaks AVEVA's proprietary on-the-wire
|
||
binary protocol to the Historian server's `aahCfgSvc` / `aahDataSetSvc` /
|
||
`aahEventSvc` listeners. The protocol is undocumented; AVEVA only ships it as
|
||
the `aahClient*` family (this folder, `aveva-install-x64\`).
|
||
|
||
**There is no AVEVA-shipped managed (.NET-Standard / net6+) replacement.** The
|
||
2023 R2 Historian Client SDK still targets .NET Framework 4.8 — verified
|
||
against AVEVA's official patch readmes through 2023 R2 P01 + 2023 P04.
|
||
|
||
So a fully-managed .NET 10 client has to come from one of three angles:
|
||
|
||
1. **Talk to a different surface** the Historian already exposes (REST, OLE DB,
|
||
SQL backend) — recommended, lowest risk.
|
||
2. **Reimplement the binary wire protocol** from scratch — highest risk,
|
||
highest performance ceiling, requires Wireshark + ILSpy reverse engineering.
|
||
3. **Build a thin ManagedShim across the existing native SDK via P/Invoke**
|
||
to `aahClient.dll` — this just relocates the .NET 4.8 wrapper into our code
|
||
and still bitness-locks us to the native DLL's architecture; the only win is
|
||
we control the API surface. Not really "fully managed."
|
||
|
||
The rest of this doc plans **option 1** as the practical path, with option 2
|
||
as the fallback if REST proves inadequate.
|
||
|
||
## What the SDK actually has to do
|
||
|
||
The minimum surface our existing `HistorianDataSource` consumes is small:
|
||
|
||
| Operation | aahClientManaged today | Used for |
|
||
|---|---|---|
|
||
| `ReadRawAsync(tag, start, end, max)` | `Connection.CreateHistoryQuery() + HistoryQueryArgs{ RetrievalMode=Full }` | `IHistoryProvider.ReadRawAsync` (OPC UA HA-Read raw) |
|
||
| `ReadAggregateAsync(tag, start, end, mode, interval)` | same path with `RetrievalMode={Average, Min, Max, ...}` | OPC UA HA-Read processed |
|
||
| `ReadAtTimeAsync(tag, timestamps[])` | per-timestamp queries with `RetrievalMode=Interpolated` and a tight window | OPC UA HA-Read at-time |
|
||
| `ReadEventsAsync(start, end)` | event-stream connection (separate from history connection) | alarm-historian replay |
|
||
| Connection health probe | open/close + ping a known tag | `IHostConnectivityProbe` |
|
||
|
||
That's the entire required surface for what runs in production today —
|
||
read-only history + events.
|
||
|
||
### The dormant write path
|
||
|
||
The sidecar's IPC contract (`Ipc/Contracts.cs:153` —
|
||
`WriteAlarmEventsRequest` / `Reply`) and dispatcher
|
||
(`Ipc/HistorianFrameHandler.cs:153` — `HandleWriteAlarmEventsAsync`) both
|
||
include a fifth operation: writing alarm events back into the historian. There
|
||
is also an `IAlarmEventWriter` abstraction.
|
||
|
||
**That write path is currently inert.** `Program.cs:57` builds the frame
|
||
handler without supplying an `alarmWriter`, so every `WriteAlarmEvents` RPC
|
||
short-circuits to `Success=false, Error="Sidecar not configured with an
|
||
alarm-event writer."`. The architecture anticipated alarm-event write-back
|
||
(probably via AVEVA's `aaInsert*` C++ entry points) but the implementation
|
||
never landed.
|
||
|
||
For the managed SDK design this means:
|
||
|
||
- **Day 1 — read-only is fine.** Shipping just the four read operations
|
||
matches every code path the OPC UA server actually executes today, with no
|
||
functional regression.
|
||
- **Day 2 — if write-back becomes a requirement**, AVEVA's REST API exposes
|
||
`POST /api/v1/Events` (and `POST /api/v1/HistorianValues` for tag samples).
|
||
Add a `WriteEventsAsync(IEnumerable<HistorianEvent>, ct)` method on
|
||
`HistorianClient`, wire `IAlarmEventWriter` to a real implementation in
|
||
`Core.AlarmHistorian` consumers, and the dormant slot lights up. No native
|
||
code or wire-protocol work required — same REST transport as the reads.
|
||
|
||
## Option 1 — REST API (recommended)
|
||
|
||
AVEVA Historian 2023+ exposes a REST API. Endpoints documented at
|
||
[`docs.aveva.com/bundle/insight/page/273809.html`](https://docs.aveva.com/bundle/insight/page/273809.html).
|
||
Same data, different transport, no native code involved.
|
||
|
||
### Steps
|
||
|
||
1. **Verify the REST endpoint is reachable on the Historian server we'll talk
|
||
to.** Older 2020 deployments may not have it on by default — toggle in
|
||
`aaConfigurator` under "Historian → Client APIs → Enable REST endpoint".
|
||
The REST listener typically binds `https://<historian-host>/Historian` on
|
||
port 32568 (or the configured TLS port). Issue a `GET /api/v1/Tags?$top=1`
|
||
to confirm.
|
||
|
||
2. **Authentication.** REST supports either ArchestrA Galaxy auth (token from
|
||
`/api/v1/auth/galaxy`) or Insight token auth. Pick one and persist the
|
||
bearer token; refresh on 401.
|
||
|
||
3. **Map the four read operations onto REST endpoints.** From the public REST
|
||
docs:
|
||
- `ReadRawAsync` → `GET /api/v1/HistorianValues?TagNames=&StartDateTime=&EndDateTime=&RetrievalMode=Full&MaxResults=`
|
||
- `ReadAggregateAsync` → same endpoint with `RetrievalMode=Average|Min|Max|Counter|Delta|Range|Stddev|Total|Integral|Slope|Interpolated|BestFit` and a `Resolution=<ms>` parameter.
|
||
- `ReadAtTimeAsync` → `RetrievalMode=ValueState` with a 1-ms window per requested timestamp, or use the bulk
|
||
`POST /api/v1/HistorianValues/AtTime` with `[{ "TagName":"...", "Time":"..." }, ...]`.
|
||
- `ReadEventsAsync` → `GET /api/v1/Events?StartDateTime=&EndDateTime=` plus filter options.
|
||
|
||
4. **Build the SDK as a single .NET 10 class library:**
|
||
|
||
```
|
||
src/AVEVA.Historian.Client/ ← new project (this repo)
|
||
├── HistorianClient.cs ← public surface (see below)
|
||
├── Auth/
|
||
│ ├── BearerTokenProvider.cs
|
||
│ └── GalaxyTokenProvider.cs ← /api/v1/auth/galaxy
|
||
├── Models/
|
||
│ ├── HistorianSample.cs ← Value, TimestampUtc, Quality
|
||
│ ├── HistorianAggregateSample.cs ← + Min/Max/Avg + Quality flags
|
||
│ ├── HistorianEvent.cs
|
||
│ └── RetrievalMode.cs ← enum mirroring REST values
|
||
├── Transport/
|
||
│ ├── HttpHistorianTransport.cs ← HttpClient, retry, timeout
|
||
│ └── ResponseParser.cs ← System.Text.Json + DateTime parsing
|
||
└── Internal/
|
||
├── ClusterEndpointPicker.cs ← port from existing sidecar
|
||
└── BackoffPolicy.cs ← Polly v8 ResiliencePipeline
|
||
```
|
||
|
||
Public surface:
|
||
|
||
```csharp
|
||
public sealed class HistorianClient : IDisposable
|
||
{
|
||
public HistorianClient(HistorianClientOptions options);
|
||
public Task<bool> ProbeAsync(CancellationToken ct = default);
|
||
public IAsyncEnumerable<HistorianSample> ReadRawAsync(
|
||
string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken ct = default);
|
||
public IAsyncEnumerable<HistorianAggregateSample> ReadAggregateAsync(
|
||
string tag, DateTime startUtc, DateTime endUtc, RetrievalMode mode,
|
||
TimeSpan interval, CancellationToken ct = default);
|
||
public Task<IReadOnlyList<HistorianSample>> ReadAtTimeAsync(
|
||
string tag, IReadOnlyList<DateTime> timestampsUtc, CancellationToken ct = default);
|
||
public IAsyncEnumerable<HistorianEvent> ReadEventsAsync(
|
||
DateTime startUtc, DateTime endUtc, CancellationToken ct = default);
|
||
}
|
||
|
||
public sealed record HistorianClientOptions(
|
||
Uri BaseUri,
|
||
ITokenProvider Tokens,
|
||
TimeSpan RequestTimeout,
|
||
int MaxParallelStreams,
|
||
string? UserAgent = null);
|
||
```
|
||
|
||
The shape mirrors the four operations the OPC UA server already calls; no
|
||
wider surface needed.
|
||
|
||
5. **Wire it into the OPC UA server.** Replace the named-pipe call sites in
|
||
`OtOpcUa.Driver.Historian.Wonderware.Client` with direct calls to
|
||
`HistorianClient`. Delete the sidecar project once the test matrix is green.
|
||
|
||
### Test strategy
|
||
|
||
- **Contract tests** — feed canned JSON responses (captured from a real
|
||
Historian) through `ResponseParser`. No network, no Historian needed.
|
||
- **Integration tests** — hit a live Historian REST endpoint (the dev VM has
|
||
one once the 2023 R2 install on the desktop completes). Same scenarios as
|
||
the existing `Driver.Historian.Wonderware.Tests` (`HistorianQualityMapper`,
|
||
raw / aggregate / events) but rewired to the new client.
|
||
- **Cluster failover** — port `HistorianClusterEndpointPicker` unchanged; the
|
||
REST layer fails the same way the SDK did (TCP error / 5xx → mark node bad).
|
||
|
||
### Why this is the recommended path
|
||
|
||
- Zero native code. Pure System.Net.Http.
|
||
- AVEVA-supported surface — the wire protocol won't change underneath us at the
|
||
next Historian patch.
|
||
- Bitness-agnostic — runs in any .NET 10 process, in-process with the OPC UA
|
||
server. The sidecar disappears.
|
||
- The four operations we need are exactly what the REST API was designed for.
|
||
No impedance mismatch.
|
||
|
||
### What it doesn't get us
|
||
|
||
- **Sub-second polling latency may regress.** REST round-trips will be slower
|
||
than a persistent binary socket on a hot LAN. Measure first; if the OPC UA
|
||
HA-Read latency budget allows, this is fine.
|
||
- **No streaming / subscription.** The native SDK supports a "live values"
|
||
push mode (`aahDataSetSvc`); the REST API is request-response. Not a
|
||
blocker for `IHistoryProvider`, would be for an `ISubscribable` over the
|
||
Historian — which we don't need today.
|
||
|
||
## Option 2 — Reverse-engineer the binary wire protocol
|
||
|
||
Only worth pursuing if REST latency is unacceptable.
|
||
|
||
### Tools
|
||
|
||
- **dnSpy** / **ILSpy** — decompile `aahClientManaged.dll` (this folder,
|
||
`current\aahClientManaged.dll`) to see the marshalling shape it expects from
|
||
the native side. The managed wrapper is small enough to read end-to-end —
|
||
it'll tell you the connection-establishment handshake, the message framing,
|
||
and the packed types of returned samples.
|
||
- **API Monitor** or **Detours hooks** on `aahClient.dll` — instrument calls
|
||
out from the wrapper to map the native ABI surface.
|
||
- **Wireshark** with a custom dissector — capture a real Historian session
|
||
(against this dev box's Historian 2020 install, port 32568) while running
|
||
`OtOpcUa.Driver.Historian.Wonderware` against it. Diff the captures across
|
||
query types to identify message kinds (raw vs aggregate vs at-time vs
|
||
events).
|
||
- **`aahDbDump.exe`** (in the AVEVA install) — dumps Historian's internal
|
||
block format. Useful for cross-checking what types you're getting on the
|
||
wire against what's stored on disk.
|
||
|
||
### Risk
|
||
|
||
The wire protocol is undocumented and AVEVA can change it across versions
|
||
without warning. A reimplementation pins us to specific Historian versions
|
||
and creates a maintenance debt that probably outweighs sidecar elimination.
|
||
|
||
This is the path you take only if (a) REST is verified inadequate AND (b)
|
||
you're willing to own a private AVEVA-protocol stack indefinitely.
|
||
|
||
## Option 3 — P/Invoke shim (rejected)
|
||
|
||
You could build a `HistorianClient` that P/Invokes directly into
|
||
`aahClient.dll`, bypassing `aahClientManaged.dll`. That technically lets the
|
||
wrapper code be net10, but:
|
||
|
||
- The bitness lock-in stays (`aahClient.dll` is x86 OR x64, no AnyCPU).
|
||
- COM-style HRESULT returns + raw `BSTR` marshalling makes the boundary
|
||
brittle.
|
||
- You learn the same things you'd learn for option 2 anyway, with no wire
|
||
format won.
|
||
|
||
Unless there's a specific feature in `aahClient` that `aahClientManaged`
|
||
doesn't expose, this path strictly loses to option 1.
|
||
|
||
## Decision matrix
|
||
|
||
| Option | Pure managed? | net10 in-proc? | Risk | Effort | Recommendation |
|
||
|---|---|---|---|---|---|
|
||
| 1 — REST | yes | yes | low | ~1–2 weeks | **default** |
|
||
| 2 — Wire-protocol reimpl | yes | yes | high | months | only if REST inadequate |
|
||
| 3 — P/Invoke shim | no (native dep) | yes (with bitness lock) | medium | ~1 week | not recommended |
|
||
|
||
## Folder layout in this directory
|
||
|
||
```
|
||
histsdk\
|
||
current\ ← the 7 SDK DLLs the sidecar links against today
|
||
aveva-install-x86\ ← full client-side DLL set from C:\Program Files (x86)\Wonderware\Historian
|
||
aveva-install-x64\ ← same, x64 build (used by our sidecar after PR 80104ca)
|
||
instructions.md ← this file
|
||
```
|
||
|
||
Use `current\` to feed dnSpy when you want to read the existing wrapper.
|
||
Use `aveva-install-*\` to inspect what other client-side surfaces AVEVA ships
|
||
that we might pivot onto (`aahClientConfig.dll` for tag-metadata browse,
|
||
`aahDataSetClient.dll` for live values, etc.).
|