docs(evidence): #700 HistoryRead through the deployed DEV vd03 endpoint (fea7de0a, HG ea9f21c)
v2-ci / unit-tests (push) Has been cancelled
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:
Binary file not shown.
@@ -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
|
||||
```
|
||||
Reference in New Issue
Block a user