docs(deploy): DEV vd03 upgraded fea7de0a -> 6c4d2990; #700 Boolean/Int32 typing + native TimeAverage/Total pass on the deployed endpoint
v2-ci / unit-tests (push) Has been cancelled
v2-ci / unit-tests (push) Has been cancelled
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
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,22 @@
|
||||
# hgmetrics-0-predeploy
|
||||
historian_gateway_grpc_requests_total{otel_scope_name="ZB.MOM.WW.HistorianGateway",otel_scope_version="1.0.0.0",method="Probe",outcome="ok",service="HistorianStatus"} 452 1794551143884
|
||||
historian_gateway_grpc_requests_total{otel_scope_name="ZB.MOM.WW.HistorianGateway",otel_scope_version="1.0.0.0",method="GetTagMetadata",outcome="ok",service="HistorianTags"} 5 1794551143884
|
||||
historian_gateway_grpc_requests_total{otel_scope_name="ZB.MOM.WW.HistorianGateway",otel_scope_version="1.0.0.0",method="ReadRaw",outcome="ok",service="HistorianRead"} 271 1794551143884
|
||||
# hgmetrics-1-postupgrade-predeploy
|
||||
historian_gateway_grpc_requests_total{otel_scope_name="ZB.MOM.WW.HistorianGateway",otel_scope_version="1.0.0.0",method="Probe",outcome="ok",service="HistorianStatus"} 464 1794551466108
|
||||
historian_gateway_grpc_requests_total{otel_scope_name="ZB.MOM.WW.HistorianGateway",otel_scope_version="1.0.0.0",method="GetTagMetadata",outcome="ok",service="HistorianTags"} 5 1794551466108
|
||||
historian_gateway_grpc_requests_total{otel_scope_name="ZB.MOM.WW.HistorianGateway",otel_scope_version="1.0.0.0",method="ReadRaw",outcome="ok",service="HistorianRead"} 271 1794551466108
|
||||
# hgmetrics-2-after-temp-deploy
|
||||
historian_gateway_grpc_requests_total{otel_scope_name="ZB.MOM.WW.HistorianGateway",otel_scope_version="1.0.0.0",method="Probe",outcome="ok",service="HistorianStatus"} 465 1794551495195
|
||||
historian_gateway_grpc_requests_total{otel_scope_name="ZB.MOM.WW.HistorianGateway",otel_scope_version="1.0.0.0",method="GetTagMetadata",outcome="ok",service="HistorianTags"} 5 1794551495195
|
||||
historian_gateway_grpc_requests_total{otel_scope_name="ZB.MOM.WW.HistorianGateway",otel_scope_version="1.0.0.0",method="ReadRaw",outcome="ok",service="HistorianRead"} 271 1794551495195
|
||||
# hgmetrics-3-after-probe
|
||||
historian_gateway_grpc_requests_total{otel_scope_name="ZB.MOM.WW.HistorianGateway",otel_scope_version="1.0.0.0",method="Probe",outcome="ok",service="HistorianStatus"} 465 1794551507122
|
||||
historian_gateway_grpc_requests_total{otel_scope_name="ZB.MOM.WW.HistorianGateway",otel_scope_version="1.0.0.0",method="GetTagMetadata",outcome="ok",service="HistorianTags"} 8 1794551507122
|
||||
historian_gateway_grpc_requests_total{otel_scope_name="ZB.MOM.WW.HistorianGateway",otel_scope_version="1.0.0.0",method="ReadRaw",outcome="ok",service="HistorianRead"} 283 1794551507122
|
||||
historian_gateway_grpc_requests_total{otel_scope_name="ZB.MOM.WW.HistorianGateway",otel_scope_version="1.0.0.0",method="ReadAggregate",outcome="ok",service="HistorianRead"} 6 1794551507122
|
||||
# hgmetrics-4-after-cleanup
|
||||
historian_gateway_grpc_requests_total{otel_scope_name="ZB.MOM.WW.HistorianGateway",otel_scope_version="1.0.0.0",method="Probe",outcome="ok",service="HistorianStatus"} 471 1794551681614
|
||||
historian_gateway_grpc_requests_total{otel_scope_name="ZB.MOM.WW.HistorianGateway",otel_scope_version="1.0.0.0",method="GetTagMetadata",outcome="ok",service="HistorianTags"} 8 1794551681614
|
||||
historian_gateway_grpc_requests_total{otel_scope_name="ZB.MOM.WW.HistorianGateway",otel_scope_version="1.0.0.0",method="ReadRaw",outcome="ok",service="HistorianRead"} 283 1794551681614
|
||||
historian_gateway_grpc_requests_total{otel_scope_name="ZB.MOM.WW.HistorianGateway",otel_scope_version="1.0.0.0",method="ReadAggregate",outcome="ok",service="HistorianRead"} 6 1794551681614
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Read-only #700 probe (6c4d2990 run: Boolean/Int32 typing + native TimeAverage/Total, read-only bindings): 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,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_<id>_<secret>), never printed.
|
||||
// Usage:
|
||||
// dotnet run -- <endpointUrl> <outJson> attrs anonymous; every Variable in the raw + UNS namespaces with Historizing/DataType
|
||||
// dotnet run -- <endpointUrl> - focas anonymous; ServiceLevel + every Variable under the FOCAS driver
|
||||
// dotnet run -- <endpointUrl> <outJson> 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<List<NodeId>> Walk(NodeId root)
|
||||
{
|
||||
var vars = new List<NodeId>(); var seen = new HashSet<NodeId>(); var queue = new Queue<NodeId>(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<object?> 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<Dictionary<string, object?>>();
|
||||
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<string, object?>();
|
||||
var cases = new List<Dictionary<string, object?>>();
|
||||
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<string, object?>();
|
||||
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<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)
|
||||
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,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
|
||||
|
@@ -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=<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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 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://<host>:4840/OtOpcUa attrs.json attrs # anonymous: historized-node inventory (#898 preflight)
|
||||
OTOPCUA_PROBE_APIKEY=<otua_ key> dotnet run -c Release -- opc.tcp://<host>:4840/OtOpcUa out.json gate
|
||||
dotnet run -c Release -- opc.tcp://<host>:4840/OtOpcUa - focas # anonymous: ServiceLevel + FOCAS reads
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user