docs(evidence): #700 HistoryRead through the deployed DEV vd03 endpoint (fea7de0a, HG ea9f21c)
v2-ci / unit-tests (push) Has been cancelled

Temporary Calculation-driver nodes bound to existing AVEVA tags (Float ZIPSEN.WTC1,
Byte SysTimeSec/SysPulse, Double missing tag) on vd03, read over OPC UA from the Mac,
then removed; the config is back at its original revision fa9a2e28. Zero historian
writes (gateway metrics: Probe/GetTagMetadata/ReadRaw only). Int32/Boolean bindings
were not deployed because they would trigger an EnsureTags upsert of the existing
AVEVA tag (#899); Float/Double/Byte never provision (#898). Includes the read-only
probe used.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
Joseph Doherty
2026-11-13 00:43:38 -05:00
parent ee39c169df
commit 5683d89bda
4 changed files with 346 additions and 0 deletions
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Read-only #700 probe: HistoryRead against a deployed OtOpcUa endpoint. Not part of the solution. -->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" />
</ItemGroup>
</Project>
@@ -0,0 +1,202 @@
// Read-only #700 probe against a deployed OtOpcUa endpoint (lmxopcua#700).
// Sends only Browse/Read/HistoryRead. Credential: env OTOPCUA_PROBE_APIKEY (otua_<id>_<secret>), never printed.
// Usage: dotnet run -- <endpointUrl> <outJson>
using System.Globalization;
using System.Text.Json;
using Opc.Ua;
using Opc.Ua.Client;
var endpointUrl = args.Length > 0 ? args[0] : "opc.tcp://wonder-app-vd03.zmr.zimmer.com:4840/OtOpcUa";
var outPath = args.Length > 1 ? args[1] : "gate700-vd03.json";
// "focas" mode (args[2]): anonymous session; reads ServiceLevel and every Variable under the FOCAS driver's raw subtree.
var focasMode = args.Length > 2 && args[2] == "focas";
var key = focasMode ? null : Environment.GetEnvironmentVariable("OTOPCUA_PROBE_APIKEY") ?? throw new InvalidOperationException("OTOPCUA_PROBE_APIKEY not set");
var ct = CancellationToken.None;
var telemetry = DefaultTelemetry.Create(_ => { });
var pki = Path.Combine(Path.GetTempPath(), $"gate700-probe-pki-{Guid.NewGuid():N}");
var appConfig = new ApplicationConfiguration(telemetry)
{
ApplicationName = "OtOpcUa.Gate700Probe",
ApplicationUri = $"urn:OtOpcUa.Gate700Probe.{Guid.NewGuid():N}",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration
{
ApplicationCertificate = new CertificateIdentifier { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(pki, "own") },
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(pki, "issuer") },
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(pki, "trusted") },
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(pki, "rejected") },
AutoAcceptUntrustedCertificates = true,
},
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 120_000 },
};
await appConfig.ValidateAsync(ApplicationType.Client, ct);
var endpoint = await CoreClientUtils.SelectEndpointAsync(appConfig, endpointUrl, false, telemetry, ct);
using var session = await new DefaultSessionFactory(telemetry).CreateAsync(appConfig,
new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(appConfig)), false, false, "Gate700Probe", 120_000,
key is null ? new UserIdentity() : new UserIdentity("apikey", System.Text.Encoding.UTF8.GetBytes(key)), default, ct);
if (focasMode)
{
var ns = (ushort)session.NamespaceUris.GetIndex("https://zb.com/otopcua/raw");
var slv = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, new[] { new ReadValueId { NodeId = VariableIds.Server_ServiceLevel, AttributeId = Attributes.Value } }, ct);
Console.WriteLine($"ServiceLevel={Fmt(Clr(slv.Results.ToList()[0].WrappedValue))}");
var vars = new List<NodeId>(); var queue = new Queue<NodeId>(new[] { new NodeId("focas-z-34184", ns) });
while (queue.Count > 0)
{
var b = await session.BrowseAsync(null, null, 0, new[] { new BrowseDescription { NodeId = queue.Dequeue(), BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, IncludeSubtypes = true, ResultMask = (uint)BrowseResultMask.All } }, ct);
foreach (var r in b.Results.ToList()[0].References.ToList())
{
var id = ExpandedNodeId.ToNodeId(r.NodeId, session.NamespaceUris);
if (r.NodeClass == NodeClass.Variable) vars.Add(id); else if (r.NodeClass == NodeClass.Object) queue.Enqueue(id);
}
}
var rv = await session.ReadAsync(null, 0, TimestampsToReturn.Both, vars.Select(v => new ReadValueId { NodeId = v, AttributeId = Attributes.Value }).ToArray(), ct);
var res = rv.Results.ToList();
var byStatus = res.GroupBy(r => SName(r.StatusCode)).Select(g => $"{g.Key}={g.Count()}");
Console.WriteLine($"FOCAS raw variables={vars.Count}: {string.Join(", ", byStatus)}");
for (var i = 0; i < vars.Count; i++) if (res[i].StatusCode.Code != 0) Console.WriteLine($" {vars[i].Identifier} {SName(res[i].StatusCode)}");
await session.CloseAsync(ct);
return 0;
}
var report = new Dictionary<string, object?>();
var cases = new List<Dictionary<string, object?>>();
report["endpoint"] = endpointUrl;
report["securityMode"] = endpoint.SecurityMode.ToString();
report["runUtc"] = DateTime.UtcNow.ToString("o");
var rawNs = session.NamespaceUris.GetIndex("https://zb.com/otopcua/raw");
NodeId N(string tag) => new($"gate700-hist/Device1/{tag}", (ushort)rawNs);
var wtc1 = N("wtc1-float"); var sec = N("systimesec-byte"); var pulse = N("syspulse-byte"); var missing = N("missing-double");
// ServiceLevel + server build
var sl = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, new[] {
new ReadValueId { NodeId = VariableIds.Server_ServiceLevel, AttributeId = Attributes.Value },
new ReadValueId { NodeId = VariableIds.Server_ServerStatus_BuildInfo_SoftwareVersion, AttributeId = Attributes.Value },
new ReadValueId { NodeId = VariableIds.Server_ServerStatus_BuildInfo_BuildNumber, AttributeId = Attributes.Value } }, ct);
var slr = sl.Results.ToList();
report["serviceLevel"] = Fmt(Clr(slr[0].WrappedValue));
report["softwareVersion"] = Fmt(Clr(slr[1].WrappedValue));
report["buildNumber"] = Fmt(Clr(slr[2].WrappedValue));
// Node attributes
var attrs = new Dictionary<string, object?>();
foreach (var (name, node) in new[] { ("wtc1-float", wtc1), ("systimesec-byte", sec), ("syspulse-byte", pulse), ("missing-double", missing) })
{
var ids = new (string Name, uint Id)[] { ("Historizing", Attributes.Historizing), ("AccessLevel", Attributes.AccessLevel),
("UserAccessLevel", Attributes.UserAccessLevel), ("DataType", Attributes.DataType) };
var r = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, ids.Select(a => new ReadValueId { NodeId = node, AttributeId = a.Id }).ToArray(), ct);
var rr = r.Results.ToList();
attrs[name] = ids.Select((a, i) => (a.Name, V: rr[i].StatusCode.Code == 0 ? Fmt(Clr(rr[i].WrappedValue)) : SName(rr[i].StatusCode)))
.ToDictionary(x => x.Name, x => (object?)x.V);
}
report["nodeAttributes"] = attrs;
// Aggregates advertised
var aggBrowse = await session.BrowseAsync(null, null, 0, new[] { new BrowseDescription {
NodeId = ObjectIds.HistoryServerCapabilities_AggregateFunctions, BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.Organizes, IncludeSubtypes = true, ResultMask = (uint)BrowseResultMask.All } }, ct);
report["aggregateFunctions"] = string.Join(",", aggBrowse.Results.ToList()[0].References.ToList().Select(r => r.BrowseName.Name).OrderBy(n => n, StringComparer.Ordinal));
DateTime U(string s) => DateTime.Parse(s, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal);
// ---- cases
await Raw("missing raw", missing, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:01:00Z"), 0);
await Processed("missing Average 20s", missing, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:01:00Z"), 20_000, ObjectIds.AggregateFunction_Average);
await Processed("missing TimeAverage 20s", missing, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:01:00Z"), 20_000, ObjectIds.AggregateFunction_TimeAverage);
await AtTime("missing AtTime", missing, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:00:30Z"));
await Raw("SysTimeSec raw 10s", sec, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:00:10Z"), 0);
await AtTime("SysTimeSec AtTime stored+mid", sec, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:00:01.5Z"));
foreach (var (n, a) in new[] { ("Average", ObjectIds.AggregateFunction_Average), ("Minimum", ObjectIds.AggregateFunction_Minimum),
("Maximum", ObjectIds.AggregateFunction_Maximum), ("Count", ObjectIds.AggregateFunction_Count), ("Start", ObjectIds.AggregateFunction_Start),
("End", ObjectIds.AggregateFunction_End), ("TimeAverage", ObjectIds.AggregateFunction_TimeAverage), ("Total", ObjectIds.AggregateFunction_Total),
("TimeAverage2", ObjectIds.AggregateFunction_TimeAverage2) })
await Processed($"SysTimeSec {n} 20s", sec, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:01:00Z"), 20_000, a);
await Processed("SysTimeSec Average 20s reverse", sec, U("2026-11-11T14:01:00Z"), U("2026-11-11T14:00:00Z"), 20_000, ObjectIds.AggregateFunction_Average);
await Modified("SysTimeSec ReadModified", sec, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:01:00Z"));
await Raw("SysPulse raw 3min", pulse, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:03:00Z"), 0);
await AtTime("SysPulse AtTime", pulse, U("2026-11-11T14:00:30Z"), U("2026-11-11T14:01:30Z"));
await Processed("SysPulse Average 60s", pulse, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:03:00Z"), 60_000, ObjectIds.AggregateFunction_Average);
await Raw("WTC1 raw 10s", wtc1, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:00:10Z"), 0);
await AtTime("WTC1 AtTime", wtc1, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:00:01Z"));
await Processed("WTC1 Average 60s over the NULL outage", wtc1, U("2026-11-10T11:31:00Z"), U("2026-11-10T11:40:00Z"), 60_000, ObjectIds.AggregateFunction_Average);
await Processed("WTC1 TimeAverage 60s", wtc1, U("2026-11-10T11:31:00Z"), U("2026-11-10T11:40:00Z"), 60_000, ObjectIds.AggregateFunction_TimeAverage);
await Raw("WTC1 end-only latest 3 before 11:38 (stopped tag reverse), first page", wtc1, DateTime.MinValue, U("2026-11-10T11:38:00Z"), 3, maxPages: 1);
await Raw("WTC1 end-only latest 3 before 11:38, 100 pages", wtc1, DateTime.MinValue, U("2026-11-10T11:38:00Z"), 3);
await Raw("WTC1 reverse raw 11:32:40 -> 11:32:20", wtc1, U("2026-11-10T11:32:40Z"), U("2026-11-10T11:32:20Z"), 0);
var now = DateTime.UtcNow;
await AtTime("WTC1 future AtTime +30m/+1h/+2h", wtc1, now.AddMinutes(30), now.AddHours(1), now.AddHours(2));
await AtTime("SysTimeSec future AtTime +30m/+1h/+2h", sec, now.AddMinutes(30), now.AddHours(1), now.AddHours(2));
report["cases"] = cases;
await File.WriteAllTextAsync(outPath, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }), ct);
await session.CloseAsync(ct);
foreach (var c in cases) Console.WriteLine($"{c["case"],-58} {c["status"],-34} rows={c["rows"]} types={c["types"]} {c["ms"]}ms");
try { Directory.Delete(pki, true); } catch { }
return 0;
// ---------------------------------------------------------------- helpers
async Task Raw(string name, NodeId node, DateTime start, DateTime end, uint max, int maxPages = 100)
{
var d = new ReadRawModifiedDetails { StartTime = start, EndTime = end, NumValuesPerNode = max, IsReadModified = false, ReturnBounds = false };
await Run(name, node, d, null, maxPages);
}
async Task Modified(string name, NodeId node, DateTime start, DateTime end)
{
var d = new ReadRawModifiedDetails { StartTime = start, EndTime = end, NumValuesPerNode = 0, IsReadModified = true };
await Run(name, node, d);
}
async Task Processed(string name, NodeId node, DateTime start, DateTime end, double interval, NodeId agg)
{
var d = new ReadProcessedDetails { StartTime = start, EndTime = end, ProcessingInterval = interval,
AggregateType = new List<NodeId> { agg }, AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true } };
await Run(name, node, d);
}
async Task AtTime(string name, NodeId node, params DateTime[] times)
{
var d = new ReadAtTimeDetails { ReqTimes = times.Select(t => (DateTimeUtc)t).ToList(), UseSimpleBounds = true };
await Run(name, node, d, times);
}
async Task Run(string name, NodeId node, IEncodeable details, DateTime[]? requested = null, int maxPages = 100)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var rows = new List<Dictionary<string, object?>>();
var types = new SortedSet<string>(StringComparer.Ordinal);
ByteString cp = default; string status; var pages = 0;
do
{
var resp = await session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, false,
new HistoryReadValueId[] { new() { NodeId = node, ContinuationPoint = cp } }, ct);
var res = resp.Results.ToList()[0];
status = SName(res.StatusCode); cp = res.ContinuationPoint; pages++;
if (!res.HistoryData.IsNull && ExtensionObject.ToEncodeable(res.HistoryData) is HistoryData hd)
foreach (var dv in hd.DataValues.ToList())
{
var v = Clr(dv.WrappedValue);
if (v is not null) types.Add(v.GetType().Name);
rows.Add(new() { ["v"] = Fmt(v), ["t"] = v?.GetType().Name, ["s"] = $"0x{dv.StatusCode.Code:X8}",
["src"] = ((DateTime)dv.SourceTimestamp).ToString("o") });
}
if (status.StartsWith("Bad", StringComparison.Ordinal)) break;
} while (!cp.IsEmpty && pages < maxPages);
sw.Stop();
var leftOpen = !cp.IsEmpty;
if (leftOpen) // release the server-side continuation point
await session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, true,
new HistoryReadValueId[] { new() { NodeId = node, ContinuationPoint = cp } }, ct);
var c = new Dictionary<string, object?> { ["case"] = name, ["node"] = node.ToString(), ["status"] = status, ["pages"] = pages,
["rows"] = rows.Count, ["types"] = string.Join("|", types), ["ms"] = sw.ElapsedMilliseconds, ["continuationLeft"] = leftOpen, ["data"] = rows };
if (requested is not null) c["requested"] = requested.Select(t => t.ToString("o")).ToList();
cases.Add(c);
}
static object? Clr(Variant value) => value.AsBoxedObject(Variant.BoxingBehavior.Legacy) switch
{
ByteString b => b.IsNull ? null : b.ToArray(), DateTimeUtc t => (DateTime)t, Uuid id => (Guid)id, var o => o,
};
static string? Fmt(object? v) => v switch { null => null, DateTime d => d.ToString("o"), IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), _ => v.ToString() };
static string SName(StatusCode code) => code.SymbolicId is { Length: > 0 } s ? s : $"0x{code.Code:X8}";
@@ -0,0 +1,130 @@
# #700: HistoryRead through the deployed DEV endpoint (wonder-app-vd03, 2026-11-13)
**Scope.** The one gap [#700](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/700) still had after the in-process gate
([`../2026-11-12-historian-694-700-746/aveva-gate.md`](../2026-11-12-historian-694-700-746/aveva-gate.md)): the same reads
through a **deployed** Host's OPC UA endpoint. Owner-approved config deploy on DEV vd03 only. Everything ran over HTTP
(AdminUI), SMB (logs, binaries) and OPC UA from the Mac; no WinRM.
**Result.** Every check that the deployed build and the no-write rule allow passed, and the wire matched the in-process
gate. Two acceptance items could not be run here, and why is the main finding:
binding a history node to an existing AVEVA tag as **Int32 or Boolean** would make OtOpcUa call the gateway's
**`EnsureTags`**, an upsert that rewrites the AVEVA tag (see [Binding](#binding-and-the-no-write-rule)). The integer case
ran on a **Byte** node instead. Boolean was not run here; it stays covered by the in-process gate only.
## Versions
| | |
|---|---|
| OtOpcUa on vd03 | **`fea7de0a`** (`OtOpcUa.Host.dll` informational version `1.0.0+fea7de0aefd9…`, read over SMB). SDK server 2.0.1, HistorianGateway Client 1.0.0. This predates `53978ca6` (native TimeAverage/Total on by default), so on vd03 the pair is still opt-in and off. |
| HistorianGateway on vd03 | **`ea9f21c`** (`ZB.MOM.WW.HistorianGateway.Server.dll` `1.0.0+ea9f21c850fb…`), `ServerHistorian` → `http://localhost:5221`. |
| Historian | AVEVA 2023 R2 on wonder-sql-vd03 (via the gateway). |
| Client | [`probe/`](probe/) (SDK 2.0.1 client, `None` security, UserName `apikey` with a temporary key). Sends only Browse, Read and HistoryRead. |
## Binding and the no-write rule
A node is history-readable only when it is an authored **raw tag** with `isHistorized: true`; `historianTagname` overrides
the AVEVA tag name (`AddressSpaceComposer` → `AddressSpaceApplier.MaterialiseRawSubtree`). Discovered nodes and virtual
tags do not register history.
Every **added** historized raw tag also goes to `AddressSpaceApplier.ProvisionHistorizedTags` → `GatewayTagProvisioner` →
gateway **`EnsureTags`**, which is `Tags.AddAsync` on the historian: create **or update**. Nothing turns this off except
`ServerHistorian:Enabled=false`, which also turns off the reads. vd03's gateway key (`OtOpcUa-wonder`, HG `/apikeys`)
carries `historian:tags:write`. So an `Int32` node on `SysTimeSec` would re-ensure that tag as Int4 with OtOpcUa's
description and storage rate, and a `Boolean` node on `SysPulse` would re-ensure a discrete tag as an Int2 analog.
The applier skips a tag whose `DataType` string does not parse as `DriverDataType` (Debug log, no request). The AdminUI's
and node manager's type names are OPC UA names, so **`Float`, `Double`, `Byte` and `SByte` never provision** (filed as a
bug: [#898](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/898)). This gate used only those types. That makes the
gate zero-write by construction, and the gateway's own counters confirm it (below).
Other write paths: `ContinuousHistorization:Enabled=false` and `AlarmHistorian:Enabled=false` in vd03's
`appsettings.json`, with no environment override (SMB read). So no recorder and no alarm sink were running.
## Temporary configuration
Authored in the AdminUI (`/scripts`, `/raw`, with the four tags imported from a CSV). Nothing under the FOCAS driver was touched.
| Object | Value |
|---|---|
| Script | `SC-cdff9670531a` `gate700-const`, `return 0;` |
| Driver | `gate700-hist`, type **Calculation** (no I/O, no endpoint), auto-created device `Device1` |
| Tags (all Read, `scriptId` above, `changeTriggered:false`, `timerIntervalMs:3600000`, `isHistorized:true`) | `wtc1-float` **Float** → `ZIPSEN.WTC1`; `systimesec-byte` **Byte** → `SysTimeSec`; `syspulse-byte` **Byte** → `SysPulse`; `missing-double` **Double** → `OtOpcUa.Gate700.DoesNotExist` |
## Deployments
| | Deployment | Revision | |
|---|---|---|---|
| Before | `9adc5590` | `fa9a2e28c1fd…` | In sync, Applied (2026-11-12 11:06Z) |
| Temporary | `a63e262f` | `c4f8d8dfbb61…` | 05:34:04Z, Applied. Log: `applied plan (kind="PureAdd", added=7 …)`, `raw subtree materialised (… tags=4 … failed=0)`. **No** `historian provisioning completed` line, so nothing was dispatched. |
| Cleanup 1 | `246c6c45` | `e9f687b7c403…` | Tags, device, driver and script removed: `PureRemove, removed=6`. The revision still differed because **the artifact carries the `ApiKeys` table** (hashed secrets only) and the temporary key still existed. |
| After | **`dc0af731`** | **`fa9a2e28c1fd…`** | After the key was deleted: the **same revision hash as before**, so the configuration is byte-identical to the original. In sync, Applied 05:40:55Z. |
## Results
Probe JSON: [`gate700-vd03-fea7de0a.json.gz`](gate700-vd03-fea7de0a.json.gz) (sha256 of the JSON `172cb3b9c1dd…`), run 2026-11-13 ~05:36Z.
ServiceLevel 250 throughout. All four nodes: `Historizing=true`, `AccessLevel=5` (CurrentRead | HistoryRead).
DataType `i=10` (Float), `i=3` (Byte), `i=3`, `i=11` (Double). Advertised aggregates: Average, Count, Delta, End, Maximum,
MaximumActualTime, Minimum, MinimumActualTime, Range, Start (10, so no TimeAverage/Total on this build).
| Case | Result |
|---|---|
| Missing tag Raw | `GoodNoData`, 0 rows |
| Missing tag Average (3 × 20 s) | 3 buckets `BadNoData \| Calculated` (`0x809B0401`), null |
| Missing tag AtTime (2 times) | 2 × `BadNoData` (`0x809B0000`) |
| `SysTimeSec` Raw 14:00:00–:10 (2026-11-11) | 10 rows 0..9, typed **Byte** (node DataType) |
| `SysTimeSec` AtTime :00 / :01.5 | 0 Good; 2 `Interpolated` (`0x00000402`), Byte |
| `SysTimeSec` Average / Min / Max / Count / Start / End, 20 s | 9.5/29.5/49.5; 0/20/40; 19/39/59; 20/20/20 (Int32); 0/20/40; 19/39/59 (End stamped at the sample). Processed values Double, as in-process. |
| `SysTimeSec` Average reverse (14:01 → 14:00) | 3 buckets newest first, stamped with the later edge |
| `SysPulse` (discrete) on a Byte node | Raw 0/1/0 each minute (Byte); AtTime :30 → 1 Interpolated (a linear midpoint on an integer node, see note 2); Average 0/1/0 |
| `ZIPSEN.WTC1` Raw / AtTime | 5 rows typed **Single**; AtTime Interpolated, Single |
| `ZIPSEN.WTC1` Average 60 s, 11:31–11:40 (2026-11-10, platform stop) | 11:31 Good 68.09; 11:32 Uncertain (`0x40A40401`); 11:33–11:37 `BadNoData \| Calculated`; 11:38 Good; 11:39 `BadNoData`. Same shape as in-process. |
| Stopped-tag reverse, end-only latest 3 before 11:38 | First page: the NULL at 11:32:36.073 (`0x80000000`), 11:32:33.620, 11:32:31.619, newest first, **96 ms**. Following the continuation points for 100 pages (300 rows) took 5.1 s. Bounded reverse 11:32:40 → 11:32:20: 8 rows newest first. |
| Future AtTime (+30 min / +1 h / +2 h) | 3 rows in request order, Uncertain (`0x40A40402`) holds of the last value, Single and Byte |
| TimeAverage / Total / TimeAverage2 | `BadAggregateNotSupported` on every node: the expected refusal on `fea7de0a` with `ServerHistorian:Gateway:EnableNativeTimeWeighted` off. The native pair is served from `53978ca6` on (#694) and is not deployed on vd03. |
| ReadModified | `BadHistoryOperationUnsupported` |
**Not run on the deployed endpoint** (each is covered by the in-process gate only):
- **Boolean node DataType**: needs an `EnsureTags` on an existing AVEVA tag ([#899](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/899)).
- **Int32 node DataType**: same reason. Integer typing was shown on Byte instead.
- **Transport outage → `BadNoCommunication`**: needs vd03's gateway stopped.
- **Strict ceiling and aggregate cooldown**: vd03's gateway is not strict, and changing its read limits needs a restart.
## Historian-write check
HistorianGateway `/metrics` (`historian_gateway_grpc_requests_total`, counters since the gateway's 2026-11-12 12:04 start):
| Snapshot | Methods seen |
|---|---|
| Before (05:27Z) | `HistorianStatus/Probe` only |
| After the temporary deploy (05:34Z) | `Probe` only, so the deploy sent no `EnsureTags` |
| After both probe runs and cleanup (05:41Z) | `Probe` 364, `HistorianTags/GetTagMetadata` 5, `HistorianRead/ReadRaw` 271. No `EnsureTags`, `AddTagExtendedProperties`, `WriteLiveValues`, `AddHistoricalValues` or `SendEvent`. |
AtTime and the processed reads reach the gateway as `ReadRaw` (`GatewayAtTimeFromRaw`, raw-evaluated aggregates), and the
missing tag as `GetTagMetadata`.
## Cleanup
- Temporary tags, device, driver and script deleted; redeployed to revision `fa9a2e28c1fd…` (the original).
- Temporary OPC UA key `gate700-temp-probe` (`cef71ff646a1cf7c`) deleted. Negative control: the same token then gets
`BadIdentityTokenRejected`. The token was never printed or written to disk.
- After cleanup: `/healthz`, `/health/ready`, `/health/active` 200; `/fleet` Applied; ServiceLevel 250. FOCAS raw subtree
(anonymous read): 22 variables, 21 Good, `Program/BlockCount` `BadNoData` (an unreported program field, `26b512ad`).
- **No history probe was kept on DEV.** It caused no writes, but it would start writing as soon as either of two things
happened: someone changed a type to Int32 or Boolean, or #898 was fixed so that `Float` provisions. Either way it would
re-ensure `ZIPSEN.WTC1` / `SysTimeSec` / `SysPulse`.
## Notes
1. The SDK logs `The domain 'opc.tcp://wonder-app-vd03.zmr.zimmer.com:4840/OtOpcUa' is not listed in the server
certificate` for every client that uses the FQDN. This is pre-existing; the client still connects.
2. An integer node interpolates AtTime linearly, and the value is converted to the node type. On a Byte node over a
discrete tag this produces a 1 at the midpoint of a 0 → 1 step. The in-process gate's Boolean node did not do this:
it held `false`. Binding a discrete tag to an integer node is this gate's workaround, not a supported pattern.
## Reproduce
```bash
cd docs/plans/evidence/2026-11-13-historian-700-vd03/probe
OTOPCUA_PROBE_APIKEY=<otua_ key from /apikeys> dotnet run -c Release -- opc.tcp://<host>:4840/OtOpcUa out.json
dotnet run -c Release -- opc.tcp://<host>:4840/OtOpcUa /dev/null focas # anonymous ServiceLevel + FOCAS raw read
```