From 9375c9e28796a5a9cfeef229a2f77f23ce5c7445 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 13 Nov 2026 01:36:29 -0500 Subject: [PATCH] docs(deploy): DEV vd03 upgraded fea7de0a -> 6c4d2990; #700 Boolean/Int32 typing + native TimeAverage/Total pass on the deployed endpoint vd03 runs master 6c4d2990 (#899/#898 read-only historian bindings), self-contained win-x64, ~15 s downtime, ServiceLevel 250, FOCAS 21/22 Good as before, runner InProcess, worker isolation re-applied after the folder swap. #898 preflight: 0 historized tags. #700 second run on the deployed endpoint with historianReadOnly bindings: Boolean (SysPulse) and Int32 (SysTimeSec) Raw/AtTime typed as the node DataType; native TimeAverage/Total 9.5/29.5/49.5 and 190/590/990 (= in-process reference), WTC1 all-NULL intervals BadNoData. HistorianGateway /metrics: no EnsureTags/WriteLiveValues or any write. Temp config + key removed, config back to rev fa9a2e28. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi --- .../2026-11-13-vd03-fea7de0a-to-6c4d2990.md | 93 ++++++++ .../deploy/qa-upgrade-b8b4c18f-to-fea7de0a.md | 3 + .../gate899-vd03-6c4d2990.json.gz | Bin 0 -> 1431 bytes .../hg-grpc-requests-6c4d2990.txt | 22 ++ .../probe-899/Gate899Probe.csproj | 14 ++ .../probe-899/Program.cs | 224 ++++++++++++++++++ .../probe-899/gate700ro-tags.csv | 4 + .../vd03-endpoint-gate.md | 102 ++++++++ 8 files changed, 462 insertions(+) create mode 100644 docs/deploy/2026-11-13-vd03-fea7de0a-to-6c4d2990.md create mode 100644 docs/plans/evidence/2026-11-13-historian-700-vd03/gate899-vd03-6c4d2990.json.gz create mode 100644 docs/plans/evidence/2026-11-13-historian-700-vd03/hg-grpc-requests-6c4d2990.txt create mode 100644 docs/plans/evidence/2026-11-13-historian-700-vd03/probe-899/Gate899Probe.csproj create mode 100644 docs/plans/evidence/2026-11-13-historian-700-vd03/probe-899/Program.cs create mode 100644 docs/plans/evidence/2026-11-13-historian-700-vd03/probe-899/gate700ro-tags.csv diff --git a/docs/deploy/2026-11-13-vd03-fea7de0a-to-6c4d2990.md b/docs/deploy/2026-11-13-vd03-fea7de0a-to-6c4d2990.md new file mode 100644 index 000000000..dfae113ef --- /dev/null +++ b/docs/deploy/2026-11-13-vd03-fea7de0a-to-6c4d2990.md @@ -0,0 +1,93 @@ +# DEV wonder-app-vd03 upgrade `fea7de0a` → master `6c4d2990` (2026-11-13) + +> **Status: EXECUTED 2026-11-13 06:25–06:26Z.** vd03 runs **`6c4d2990`** (`6c4d29902e18d7560ce1ff5607d524a58f674440`). +> Owner-approved as part of the #700 hybrid decision: deploy a master build carrying the #899/#898 read-only historian +> binding, then run the remaining #700 checks on the deployed endpoint (record: +> [`../plans/evidence/2026-11-13-historian-700-vd03/vd03-endpoint-gate.md`](../plans/evidence/2026-11-13-historian-700-vd03/vd03-endpoint-gate.md), second run). + +Channel: WinRM + SMB from the Mac (the `winrm` skill), used only for the on-box steps of this deploy (preflight/backup, +swap, the worker-isolation re-apply). Health, OPC UA and AdminUI checks ran from the Mac over HTTP / opc.tcp. Layout and +scripts as §(h) of [`vd03-upgrade-b8b4c18f-to-master.md`](vd03-upgrade-b8b4c18f-to-master.md) and the DEV section of +[`qa-upgrade-b8b4c18f-to-fea7de0a.md`](qa-upgrade-b8b4c18f-to-fea7de0a.md). + +**Pinned SHA.** The deploy is pinned to `6c4d2990`. `origin/master` moved to `efaf3435` (#900) during the run; that commit +is **not** deployed. + +## Build + +- `dotnet publish src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj -c Release -r win-x64 --self-contained true` + on the Mac, in an isolated worktree at `6c4d2990`: **1396 files**, `OtOpcUa.Host.dll` ProductVersion + `1.0.0+6c4d29902e18d7560ce1ff5607d524a58f674440`, self-contained `script-worker\`. +- `deps.json`: Telemetry + Telemetry.Serilog **0.3.1** (not 0.3.0, scadaproj#56), LocalDb 0.3.7, MxGateway.Client 0.4.0, + HistorianGateway.Client 1.0.0, Auth 0.4.1, Secrets 0.6.2, Health 0.3.0, Theme 0.5.0. +- Zip `otopcua-6c4d2990.zip`, SHA-256 `4914D083…FC0850` (full hash in the staged `.sha256`), checked on the box before expanding. +- `fea7de0a..6c4d2990`: 37 commits, **no ConfigDb migration** (last stays `20261022102459_EnableSnapshotIsolation`), no + new required setting. Client-visible on vd03: TimeAverage/Total advertised and served again (#694, `53978ca6`); a + Boolean node refuses TimeAverage/Total up front (#897); Telemetry 0.3 sends **logs** over OTLP too, because vd03 has + `OtOpcUa__Telemetry__Exporter=Otlp` + `OtlpEndpoint=http://127.0.0.1:4317` (scheme present, so the stricter 0.3 + endpoint check passes). + +## Preflight (stop conditions) + +| Check | Result | +|---|---| +| **#898 upgrade hazard**: historized Float/Double/Byte/SByte tags that the first start would `EnsureTags` | **None.** Anonymous walk of the deployed address space (raw + UNS, `probe-899 attrs`): 24 variables, **0 with `Historizing=true`**. `/deployments` "In sync" at `dc0af731` (rev `fa9a2e28…`), so draft = deployed. | +| Continuous / alarm historization | Off: `ContinuousHistorization:Enabled=false`, `AlarmHistorian:Enabled=false` in `appsettings.json`; no `*__Enabled` environment override. `ServerHistorian__Enabled=true` (env). `ServerHistorian__ProvisionTags` absent (default `CreateOrUpdate`); with 0 historized tags nothing can be provisioned. | +| Shared-lib pins on master | Telemetry already **0.3.1** at `6c4d2990` (`f3178c5f`); no bump needed. | +| Migrations in range | None. ConfigDb at 9 migrations, last `20261022102459_EnableSnapshotIsolation`, snapshot isolation ON. | +| Service env | 28 variables (names checked, values not printed): +2 since the fea7de0a record (`OtOpcUa__Telemetry__Exporter`, `OtOpcUa__Telemetry__OtlpEndpoint`). `Scripting__Runner` absent → `InProcess` (unchanged). | +| Disk | E: 179 GB free. | + +## Execution + +Staged in `E:\ApiInstall\_stage-otopcua-6c4d2990\` (`prepare.ps1`, `swap.ps1`, `rollback.ps1`, `reisolate.ps1`, zip + `.sha256`). + +- **Prepare (service up), `prepare.ps1`:** registry export of the service key to + `E:\ApiInstall\OtOpcUa-backups\pre-20261113-otopcua-6c4d2990\OtOpcUaHost.reg` (DACL SYSTEM + Administrators only); + zip hash ok, expanded to `E:\ApiInstall\OtOpcUa.new-6c4d2990` (1396 files, ProductVersion checked); + `BACKUP DATABASE [OtOpcUaConfig] … COPY_ONLY, CHECKSUM` → `OtOpcUaConfig-pre-20261113-otopcua-6c4d2990.bak` + (instance default backup directory, 1913 pages). +- **Swap, `swap.ps1`** (06:25:50Z): stop (6 s), `OtOpcUa-data` robocopied to + `E:\ApiInstall\OtOpcUa-backups\pre-20261113-otopcua-6c4d2990\OtOpcUa-data`, previous install moved to + **`E:\ApiInstall\OtOpcUa.bak.20261113-pre-6c4d2990`**, box `appsettings*`, `pki\`, `logs\` carried, start, + `/health/ready` 200. **Downtime about 15 s** (stop → ready). +- **Worker isolation re-applied, `reisolate.ps1`** (lmxopcua#891 arguments, no `-ConfigureHostService`): the folder swap + had dropped three ACEs, as `docs/ServiceHosting.md` warns — `WorkerFolderAcl` (`script-worker\`), `InstallRootDeny` + (`E:\ApiInstall\OtOpcUa`) and `SensitiveDirDeny` (`…\OtOpcUa\pki`) applied as **changed**; the immediate re-apply + reported **every step unchanged**. The runner stays `InProcess`; no restart needed. The same 6 pre-existing + "protected DACL" warnings under `E:\psrem-stage` as before. + +## Verification + +- `/healthz`, `/health/ready` (configdb, akka Up, opcua-endpoint, script runner, deploy coordinator) and `/health/active`: 200. +- **Running version = target:** the live `E:\ApiInstall\OtOpcUa\OtOpcUa.Host.dll` (SMB copy) carries + `1.0.0+6c4d29902e18…`; its SHA256 prefix `6d1f354f674e` equals the published build's. +- **ServiceLevel 250** (unchanged). +- **FOCAS:** raw subtree 22 variables, **21 Good**, `Program/BlockCount` `BadNoData` — the same set as before the upgrade. +- Boot log: `roles=[admin,driver]`, `Historian gateway probe: the gateway is reachable`, **12** aggregates advertised + (Average, TimeAverage, Total, …), `applied plan (PureAdd, added=36 …)`, `raw subtree materialised (… tags=22 … failed=0)`, + the #815 preflight line, no provisioning line. Pre-existing noise only (Akka `auto-down` deprecation, early + `ClusterSingletonProxy` warnings, mesh PSK absent, FQDN-vs-certificate ERR from clients dialling the FQDN). +- HistorianGateway `/metrics` across the upgrade: `Probe` only, no `EnsureTags` (see the evidence file). +- Script runner unchanged (`InProcess`, no `Scripting__*` env). + +## #700 checks (after the upgrade) + +Boolean + Int32 typing and native TimeAverage/Total passed through the deployed endpoint with `historianReadOnly: true` +bindings, zero historian writes, and the configuration restored byte-identical (rev `fa9a2e28…`, deployment `595cb909`). +Details: [evidence, second run](../plans/evidence/2026-11-13-historian-700-vd03/vd03-endpoint-gate.md). + +## Rollback + +`E:\ApiInstall\_stage-otopcua-6c4d2990\rollback.ps1`: stop; move the live folder to `E:\ApiInstall\OtOpcUa.failed-6c4d2990`; +restore `E:\ApiInstall\OtOpcUa.bak.20261113-pre-6c4d2990` (`fea7de0a`); restore `OtOpcUa-data` from the pre-deploy backup; +start; re-apply the worker isolation. No schema undo (no migration in range). The ConfigDb `.bak` is there if the +configuration itself must go back. + +## Observed (not deploy blockers) + +- Closing an AdminUI tab that had the script editor open logs `[ERR] Unhandled exception in circuit …` + `TaskCanceledException` from `MonacoEditor.DisposeAsync` → `SafeInvokeAsync` (the JS runtime is already gone when the + circuit disposes). Cosmetic. +- `/deployments` showed the "In sync" chip on first render after a draft change; it switched to "Configuration drift" + after the next interaction. diff --git a/docs/deploy/qa-upgrade-b8b4c18f-to-fea7de0a.md b/docs/deploy/qa-upgrade-b8b4c18f-to-fea7de0a.md index 0247934e5..2549fd15c 100644 --- a/docs/deploy/qa-upgrade-b8b4c18f-to-fea7de0a.md +++ b/docs/deploy/qa-upgrade-b8b4c18f-to-fea7de0a.md @@ -4,6 +4,9 @@ > `fea7de0a` (from `b8b4c18f`) with the out-of-process script runner on (Calculation + VirtualTag seams) and the > ScriptWorker isolation installed. Tracking: #801 (SDK 2 live gate), #793 (ConfigDb preflight), #794 (worker > isolation install), #780 (P3 runner flip). Template: [`vd03-upgrade-b8b4c18f-to-master.md`](vd03-upgrade-b8b4c18f-to-master.md). +> +> **DEV vd03 moved on 2026-11-13:** `fea7de0a` → `6c4d2990` (#899 read-only historian bindings; #700 checks). Record: +> [`2026-11-13-vd03-fea7de0a-to-6c4d2990.md`](2026-11-13-vd03-fea7de0a-to-6c4d2990.md). QA is unchanged. Channel: WinRM + SMB from the Mac (the `winrm` skill), used only for the on-box steps of this deploy, which the owner requested on 2026-11-12. Health, OPC UA, ScadaBridge and Overview checks ran from the Mac over HTTP / opc.tcp. diff --git a/docs/plans/evidence/2026-11-13-historian-700-vd03/gate899-vd03-6c4d2990.json.gz b/docs/plans/evidence/2026-11-13-historian-700-vd03/gate899-vd03-6c4d2990.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..fac7137088313180aa229542925bc1e6d87d275c GIT binary patch literal 1431 zcmV;I1!(#oiwFqLuJ&pI17~4$WjHxGEp}uuGc7h_G-NV4IWR72b8l_{?V4Lt<2D$E z@A(y;^roH3L1)S7a1oZ$?hLS<655$G9`RdL^OHM~>5w;b0O4p=xxUMl|u;%Aa&Qo%<*p$hhTACat>x!0pFz+fs_9NK@5i6v}ByoQBuRH!%bdYeCq8xDQwt!!Chs zOkIW<`1o8)d>8ogT;9o1b_E5qf@rAjy;%M(<0ObeQ#LKQNrTbgG9BN@cpeNjL{F(W zxJi{vbo{f9npz%`Njgeo>n4guc~}?x5+o{$gU2AeHMR8PbPAsp_IzI^NzO#)CD)pF zd+DjCy#B)^OGj`F8(I9RnWLy+EOpviI{6swnT9RX+$$fphNH++`_2lOfgH_X8s6<~ zEN?v}&(hFW`dORj*XZ5J*t?a@cTpOu=BXShulZ9rXkK`qf^j;2e(m`x^+tW2*ycqL zPD`e*3njmKp|0sld9iAmz;oUGNO_~FfP62>bn|BhmnX!84b9a<@~hF26MOd#9tWZG z{fSnT^g_o-?}z!_hO_PvCdcT<icZvvz!ddDjgExhOuY4*$s9HZ4HL@Xke{qUOF2!L*$k8 zv-K4;;?1yjk?!&$dw2ChP)xoAR4QLk{t;R9a%htVmZd>9X;4`jY?B6;r6D$HNLd2$d!xmWfCu zOM}H-l((g5NZo^^50c&>>1==NjSbJd+U*(_)Jx0ME>L+XyLN#pADoeGLEGJ$MHaSZ zw^>*$^&g4+b|+_chiOb?oRg2Yuf5~4eRG<Uvq>JUDxha5ZD-FMBLx*!#YUkFq%7ZiyFSU1TH$O!4 z&>H23X&zCd{D@8ZDc-SH&tXLRy`%n-&gJ#_xf7*I7wo&EyG2ckR@%{-Xag5w#37`D zQYdT;$e<6QIrUxMvJ9Rel)4xZ2oXo+@CfLUqIjr0UYL$XYdmYg8H)h!Nl_MDBNkF4 z7Fr_~RwEX^L)U7-HUk1ODqP_rE(8_!I+xfDkWhdq1OhQkFoD?K;N-sK+{)Bc@-u30 z%1)|1hf08;ZNgoKm`fpsuwHh82xV;m3D&0K3J%4=P7Zc*u#-ycL@)md0hDmT0LCsQ zHFFceb;$w0!{(b4!ZGDGcP2!Ql2yz*j?BBK{{X256${5d000^5v9 + + + Exe + net10.0 + enable + enable + false + false + + + + + diff --git a/docs/plans/evidence/2026-11-13-historian-700-vd03/probe-899/Program.cs b/docs/plans/evidence/2026-11-13-historian-700-vd03/probe-899/Program.cs new file mode 100644 index 000000000..1a8bf7f86 --- /dev/null +++ b/docs/plans/evidence/2026-11-13-historian-700-vd03/probe-899/Program.cs @@ -0,0 +1,224 @@ +// Read-only #700 probe, second vd03 run (master 6c4d2990, read-only historian bindings #899). +// Sends only Browse/Read/HistoryRead. Credential (gate mode only): env OTOPCUA_PROBE_APIKEY (otua__), never printed. +// Usage: +// dotnet run -- attrs anonymous; every Variable in the raw + UNS namespaces with Historizing/DataType +// dotnet run -- - focas anonymous; ServiceLevel + every Variable under the FOCAS driver +// dotnet run -- gate apikey; Boolean/Int32/Float typing + native TimeAverage/Total +using System.Globalization; +using System.Text.Json; +using Opc.Ua; +using Opc.Ua.Client; + +var endpointUrl = args[0]; +var outPath = args[1]; +var mode = args[2]; +var key = mode == "gate" ? Environment.GetEnvironmentVariable("OTOPCUA_PROBE_APIKEY") ?? throw new InvalidOperationException("OTOPCUA_PROBE_APIKEY not set") : null; +var ct = CancellationToken.None; +var telemetry = DefaultTelemetry.Create(_ => { }); +var pki = Path.Combine(Path.GetTempPath(), $"gate899-probe-pki-{Guid.NewGuid():N}"); +var appConfig = new ApplicationConfiguration(telemetry) +{ + ApplicationName = "OtOpcUa.Gate899Probe", + ApplicationUri = $"urn:OtOpcUa.Gate899Probe.{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, "Gate899Probe", 120_000, + key is null ? new UserIdentity() : new UserIdentity("apikey", System.Text.Encoding.UTF8.GetBytes(key)), default, ct); + +var rawNs = (ushort)session.NamespaceUris.GetIndex("https://zb.com/otopcua/raw"); +var unsNs = (ushort)session.NamespaceUris.GetIndex("https://zb.com/otopcua/uns"); + +async Task> Walk(NodeId root) +{ + var vars = new List(); var seen = new HashSet(); var queue = new Queue(new[] { root }); + 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 (id.NamespaceIndex != rawNs && id.NamespaceIndex != unsNs) continue; // skip Server object + ns0 types + if (!seen.Add(id)) continue; + if (r.NodeClass == NodeClass.Variable) vars.Add(id); else if (r.NodeClass == NodeClass.Object) queue.Enqueue(id); + } + } + return vars; +} +async Task ReadServiceLevel() +{ + var slv = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, new[] { new ReadValueId { NodeId = VariableIds.Server_ServiceLevel, AttributeId = Attributes.Value } }, ct); + return Clr(slv.Results.ToList()[0].WrappedValue); +} + +if (mode == "attrs") +{ + var vars = await Walk(ObjectIds.ObjectsFolder); + var rows = new List>(); + foreach (var v in vars) + { + var r = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, new[] { + new ReadValueId { NodeId = v, AttributeId = Attributes.Historizing }, + new ReadValueId { NodeId = v, AttributeId = Attributes.DataType }, + new ReadValueId { NodeId = v, AttributeId = Attributes.AccessLevel } }, ct); + var rr = r.Results.ToList(); + rows.Add(new() { ["node"] = v.ToString(), ["historizing"] = Fmt(Clr(rr[0].WrappedValue)), ["dataType"] = Fmt(Clr(rr[1].WrappedValue)), ["accessLevel"] = Fmt(Clr(rr[2].WrappedValue)) }); + } + var hist = rows.Where(r => (string?)r["historizing"] == "True").ToList(); + Console.WriteLine($"ServiceLevel={Fmt(await ReadServiceLevel())} variables={rows.Count} (raw+uns) historizing={hist.Count}"); + foreach (var h in hist) Console.WriteLine($" HISTORIZED {h["node"]} dataType={h["dataType"]} accessLevel={h["accessLevel"]}"); + if (outPath != "-") await File.WriteAllTextAsync(outPath, JsonSerializer.Serialize(rows, new JsonSerializerOptions { WriteIndented = true }), ct); + await session.CloseAsync(ct); + return 0; +} + +if (mode == "focas") +{ + Console.WriteLine($"ServiceLevel={Fmt(await ReadServiceLevel())}"); + var vars = await Walk(new NodeId("focas-z-34184", rawNs)); + 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(); + Console.WriteLine($"FOCAS raw variables={vars.Count}: {string.Join(", ", res.GroupBy(r => SName(r.StatusCode)).Select(g => $"{g.Key}={g.Count()}"))}"); + 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; +} + +// ---------------------------------------------------------------- gate mode +var report = new Dictionary(); +var cases = new List>(); +report["endpoint"] = endpointUrl; +report["securityMode"] = endpoint.SecurityMode.ToString(); +report["runUtc"] = DateTime.UtcNow.ToString("o"); + +NodeId N(string tag) => new($"gate700ro/Device1/{tag}", rawNs); +var pulse = N("syspulse-bool"); var sec = N("systimesec-int32"); var wtc1 = N("wtc1-float"); + +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)); + +var attrs = new Dictionary(); +foreach (var (name, node) in new[] { ("syspulse-bool", pulse), ("systimesec-int32", sec), ("wtc1-float", wtc1) }) +{ + 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; + +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); + +// Boolean typing: discrete SysPulse on a Boolean node (same windows as the first vd03 run and the in-process gate). +await Raw("SysPulse raw 3min (Boolean node)", pulse, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:03:00Z"), 0); +await AtTime("SysPulse AtTime :30/1:30 (Boolean node)", pulse, U("2026-11-11T14:00:30Z"), U("2026-11-11T14:01:30Z")); +await AtTime("SysPulse AtTime stored :00/:01 (Boolean node)", pulse, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:01:00Z")); +await Processed("SysPulse TimeAverage 60s (Boolean node, refused up front #897)", pulse, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:03:00Z"), 60_000, ObjectIds.AggregateFunction_TimeAverage); +await Processed("SysPulse Total 60s (Boolean node, refused up front #897)", pulse, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:03:00Z"), 60_000, ObjectIds.AggregateFunction_Total); +await Processed("SysPulse Count 60s (Boolean node)", pulse, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:03:00Z"), 60_000, ObjectIds.AggregateFunction_Count); + +// Int32 typing: SysTimeSec on an Int32 node. +await Raw("SysTimeSec raw 10s (Int32 node)", sec, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:00:10Z"), 0); +await AtTime("SysTimeSec AtTime stored+mid (Int32 node)", sec, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:00:01.5Z")); +foreach (var (n, a) in new[] { ("Average", ObjectIds.AggregateFunction_Average), ("TimeAverage", ObjectIds.AggregateFunction_TimeAverage), + ("Total", ObjectIds.AggregateFunction_Total), ("TimeAverage2", ObjectIds.AggregateFunction_TimeAverage2) }) + await Processed($"SysTimeSec {n} 20s (Int32 node)", sec, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:01:00Z"), 20_000, a); +await Processed("SysTimeSec TimeAverage 20s reverse (Int32 node)", sec, U("2026-11-11T14:01:00Z"), U("2026-11-11T14:00:00Z"), 20_000, ObjectIds.AggregateFunction_TimeAverage); +await Processed("SysTimeSec Total 20s reverse (Int32 node)", sec, U("2026-11-11T14:01:00Z"), U("2026-11-11T14:00:00Z"), 20_000, ObjectIds.AggregateFunction_Total); + +// Native pair on an analog Float node, over the platform-stop window (all-NULL intervals => BadNoData). +await Raw("WTC1 raw 10s (Float node)", wtc1, U("2026-11-11T14:00:00Z"), U("2026-11-11T14:00:10Z"), 0); +await Processed("WTC1 TimeAverage 60s over the NULL outage", wtc1, U("2026-11-10T11:31:00Z"), U("2026-11-10T11:40:00Z"), 60_000, ObjectIds.AggregateFunction_TimeAverage); +await Processed("WTC1 Total 60s over the NULL outage", wtc1, U("2026-11-10T11:31:00Z"), U("2026-11-10T11:40:00Z"), 60_000, ObjectIds.AggregateFunction_Total); +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); + +report["cases"] = cases; +await File.WriteAllTextAsync(outPath, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }), ct); +await session.CloseAsync(ct); +Console.WriteLine($"ServiceLevel={report["serviceLevel"]} version={report["softwareVersion"]} build={report["buildNumber"]}"); +Console.WriteLine($"Aggregates: {report["aggregateFunctions"]}"); +foreach (var (k, v) in attrs) Console.WriteLine($"{k}: {JsonSerializer.Serialize(v)}"); +foreach (var c in cases) Console.WriteLine($"{c["case"],-66} {c["status"],-30} 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 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 { 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>(); + var types = new SortedSet(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) + await session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, true, + new HistoryReadValueId[] { new() { NodeId = node, ContinuationPoint = cp } }, ct); + var c = new Dictionary { ["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}"; diff --git a/docs/plans/evidence/2026-11-13-historian-700-vd03/probe-899/gate700ro-tags.csv b/docs/plans/evidence/2026-11-13-historian-700-vd03/probe-899/gate700ro-tags.csv new file mode 100644 index 000000000..1673a998d --- /dev/null +++ b/docs/plans/evidence/2026-11-13-historian-700-vd03/probe-899/gate700ro-tags.csv @@ -0,0 +1,4 @@ +Name,DataType,AccessLevel,IsHistorized,HistorianTagname,HistorianReadOnly,ScriptId,ChangeTriggered,TimerIntervalMs +syspulse-bool,Boolean,Read,true,SysPulse,true,SC-d910aa826b18,false,3600000 +systimesec-int32,Int32,Read,true,SysTimeSec,true,SC-d910aa826b18,false,3600000 +wtc1-float,Float,Read,true,ZIPSEN.WTC1,true,SC-d910aa826b18,false,3600000 diff --git a/docs/plans/evidence/2026-11-13-historian-700-vd03/vd03-endpoint-gate.md b/docs/plans/evidence/2026-11-13-historian-700-vd03/vd03-endpoint-gate.md index ac5078a38..cdb64b69d 100644 --- a/docs/plans/evidence/2026-11-13-historian-700-vd03/vd03-endpoint-gate.md +++ b/docs/plans/evidence/2026-11-13-historian-700-vd03/vd03-endpoint-gate.md @@ -11,6 +11,10 @@ binding a history node to an existing AVEVA tag as **Int32 or Boolean** would ma **`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. +> **Update 2026-11-13 ~06:31Z:** both gaps are now closed on the deployed endpoint. vd03 was upgraded to `6c4d2990` +> (#899 read-only bindings) and Boolean, Int32 and native TimeAverage/Total passed with zero historian writes. See +> [Second run](#second-run-boolean--int32-typing-and-native-timeaverage--total-on-master-6c4d2990-2026-11-13-0631z) below. + ## Versions | | | @@ -128,3 +132,101 @@ cd docs/plans/evidence/2026-11-13-historian-700-vd03/probe OTOPCUA_PROBE_APIKEY= dotnet run -c Release -- opc.tcp://:4840/OtOpcUa out.json dotnet run -c Release -- opc.tcp://:4840/OtOpcUa /dev/null focas # anonymous ServiceLevel + FOCAS raw read ``` + +--- + +# Second run: Boolean / Int32 typing and native TimeAverage / Total on master `6c4d2990` (2026-11-13 ~06:31Z) + +**Scope.** The two items the owner's hybrid decision on #700 left for the deployed endpoint: **Boolean and Int32 node +typing** and **native TimeAverage / Total** (default-on since `53978ca6`). vd03 was first upgraded `fea7de0a` → +**`6c4d2990`** (record: [`../../../deploy/2026-11-13-vd03-fea7de0a-to-6c4d2990.md`](../../../deploy/2026-11-13-vd03-fea7de0a-to-6c4d2990.md)), +which carries the #899 read-only historian binding. The outage, strict-ceiling and cooldown items are accepted on the +in-process evidence (owner decision, #700 comment 2026-11-13 00:49). + +**Result: both pass, zero historian writes.** + +## Versions + +| | | +|---|---| +| OtOpcUa on vd03 | **`6c4d2990`** (`OtOpcUa.Host.dll` informational version `1.0.0+6c4d29902e18…`, read over SMB; SHA256 prefix of the live DLL equals the published build's, `6d1f354f674e…`). SDK 2.0.1, HG Client 1.0.0, Telemetry 0.3.1. `ServerHistorian:ProvisionTags` absent (default `CreateOrUpdate`); `EnableNativeTimeWeighted` absent (default `true`). | +| HistorianGateway on vd03 | `ea9f21c` (unchanged). | +| Client | [`probe-899/`](probe-899/) — SDK 2.0.1 client, `None` security, UserName `apikey` with a temporary key; Browse / Read / HistoryRead only. | + +## Temporary configuration (zero-write binding) + +Authored in the AdminUI: script `SC-d910aa826b18` `gate700ro-const` (`return 0;`), Calculation driver `gate700ro`, +auto-created device `Device1` (`DEV-b784b898d5d3`), three tags imported from [`probe-899/gate700ro-tags.csv`](probe-899/gate700ro-tags.csv). +Every tag is `Read`, `isHistorized: true`, **`historianReadOnly: true`** (CSV export after commit confirmed the flag +stored on all three): + +| Tag | Node DataType | AVEVA tag | +|---|---|---| +| `syspulse-bool` | **Boolean** | `SysPulse` (discrete) | +| `systimesec-int32` | **Int32** | `SysTimeSec` (UInt2 analog) | +| `wtc1-float` | Float | `ZIPSEN.WTC1` | + +| | Deployment | Revision | | +|---|---|---|---| +| Before | `dc0af731` | `fa9a2e28c1fd…` | In sync | +| Temporary | `5e40eeeb` | `78b18b6e6f12…` | 06:31:21Z, Applied. Log: `applied plan (kind="PureAdd", added=6 …)`, `raw subtree materialised (containers=2, tags=3 … failed=0)`. No provisioning line. | +| After | **`595cb909`** | **`fa9a2e28c1fd…`** | 06:34:19Z, Applied, `PureRemove, removed=5`. Same revision hash as before (tags, device, driver, script **and** the temp key deleted first), so the configuration is byte-identical to the original. | + +## Results + +Probe JSON: [`gate899-vd03-6c4d2990.json.gz`](gate899-vd03-6c4d2990.json.gz) (sha256 of the JSON `3ae90a846650…`). +ServiceLevel 250. All three nodes `Historizing=true`, `AccessLevel=5`; DataType **`i=1` (Boolean)**, **`i=6` (Int32)**, +`i=10` (Float). `HistoryServerCapabilities/AggregateFunctions` now advertises **12**: the ten of the first run plus +**TimeAverage and Total**. + +| Case | Result | +|---|---| +| `SysPulse` Raw 14:00–14:03 (2026-11-11), **Boolean node** | 3 rows `False` / `True` / `False`, typed **Boolean**, Good ✅ | +| `SysPulse` AtTime :00 / 1:00 (stored) and :30 / 1:30 | `False` / `True` Good; `False` / `True` `Interpolated` (`0x00000402`) — the Boolean node holds the step (the Byte node of the first run gave a linear-midpoint 1, note 2 above), typed **Boolean** ✅ | +| `SysPulse` Count 60 s | 1 / 1 / 1, Int32 | +| `SysPulse` TimeAverage / Total | `BadAggregateNotSupported` up front (#897: AVEVA refuses both on a discrete tag) — no gateway call | +| `SysTimeSec` Raw 14:00:00–:10, **Int32 node** | 10 rows 0..9 typed **Int32** ✅ | +| `SysTimeSec` AtTime :00 / :01.5 | 0 Good; 2 `Interpolated`, typed **Int32** ✅ (linear 1.5 converted to the node type; the in-process gate recorded 1 at its midpoint, the first vd03 run 2 on a Byte node) | +| **`SysTimeSec` TimeAverage 20 s** (forward and reverse) | 9.5 / 29.5 / 49.5 (`0x00000401`), reverse newest first stamped with the later edge ✅ — equal to the in-process step reference (`aveva-gate.md` #694 item 3) | +| **`SysTimeSec` Total 20 s** (forward and reverse) | 190 / 590 / 990 ✅ — equal to the in-process reference | +| `SysTimeSec` Average 20 s | 9.5 / 29.5 / 49.5 (raw-evaluated) | +| `SysTimeSec` TimeAverage2 | `BadAggregateNotSupported` (still refused) | +| `ZIPSEN.WTC1` Raw | 5 rows typed **Single** | +| **`ZIPSEN.WTC1` TimeAverage / Total 60 s, 11:31–11:40 (2026-11-10, platform stop)** | 11:31 Good 68.091 / 4085.48; 11:32 Uncertain (`0x40A40401`) 68.085 / 2456.01; **11:33–11:37 `BadNoData \| Calculated`** (all-NULL intervals); 11:38 Uncertain; 11:39 Good ✅ | + +## Historian-write check + +HistorianGateway `/metrics` (`historian_gateway_grpc_requests_total`, all `outcome="ok"`); full lines in +[`hg-grpc-requests-6c4d2990.txt`](hg-grpc-requests-6c4d2990.txt): + +| Snapshot (UTC) | Probe | GetTagMetadata | ReadRaw | ReadAggregate | Anything else | +|---|---|---|---|---|---| +| Before the upgrade (06:25) | 452 | 5 | 271 | — | none | +| After the upgrade, before the temp deploy (06:31:06) | 464 | 5 | 271 | — | none — the first start of `6c4d2990` provisioned nothing (vd03 had 0 historized tags, #898 preflight) | +| After the temp deploy (06:31:35) | 465 | 5 | 271 | — | **none — the read-only bindings sent no `EnsureTags`** | +| After the probe (06:31:47) | 465 | 8 | 283 | **6** | none | +| After cleanup (06:34:41) | 471 | 8 | 283 | 6 | none | + +No `EnsureTags`, `WriteLiveValues`, `AddTagExtendedProperties`, `AddHistoricalValues` or `SendEvent` at any point. The six +`ReadAggregate` calls are exactly the six native TimeAverage/Total reads (SysTimeSec ×4, WTC1 ×2); the raw-evaluated +aggregates and AtTime ride `ReadRaw`; the three `GetTagMetadata` came during the probe reads, not the deploy. The +OtOpcUa log has no provisioning line for either deployment. `ContinuousHistorization` and `AlarmHistorian` are off +(`appsettings.json`, no environment override). + +## Cleanup + +- Tags, device, driver and script deleted; redeployed to revision `fa9a2e28c1fd…` (the original); `/fleet` 1 node Applied, 0 failed. +- Temporary OPC UA key `gate700ro-temp-probe` (`60f99e0e57242953`) deleted. Negative control: the same token then gets + `BadIdentityTokenRejected`. The token went from the page to the Mac clipboard to the probe's environment and was never + printed or written to disk; the clipboard was cleared afterwards. +- After cleanup: `/healthz`, `/health/ready`, `/health/active` 200; ServiceLevel 250; FOCAS raw subtree 22 variables, + 21 Good, `Program/BlockCount` `BadNoData` (as before); 0 historized variables in the address space. + +## Reproduce + +```bash +cd docs/plans/evidence/2026-11-13-historian-700-vd03/probe-899 +dotnet run -c Release -- opc.tcp://:4840/OtOpcUa attrs.json attrs # anonymous: historized-node inventory (#898 preflight) +OTOPCUA_PROBE_APIKEY= dotnet run -c Release -- opc.tcp://:4840/OtOpcUa out.json gate +dotnet run -c Release -- opc.tcp://:4840/OtOpcUa - focas # anonymous: ServiceLevel + FOCAS reads +```